Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 259 additions & 1 deletion server/api/internal/rpc/[chainId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ const ALLOWED_METHODS = new Set([
])

const MAX_BATCH_SIZE = 100
const MAX_PARAMS_BYTES = 256 * 1024
const MAX_CALLDATA_BYTES = 256 * 1024
const MAX_EXPLICIT_GAS = 50_000_000n
const MAX_STATE_OVERRIDE_ACCOUNTS = 128
const MAX_STATE_OVERRIDE_SLOTS = 2_048
const MAX_STATE_OVERRIDE_CODE_BYTES = 64 * 1024
const MAX_FEE_HISTORY_BLOCKS = 128n
const MAX_REWARD_PERCENTILES = 20
const MAX_LOG_BLOCK_RANGE = 10_000n
const MAX_LOG_ADDRESSES = 20
const MAX_LOG_TOPIC_POSITIONS = 4
const MAX_LOG_TOPIC_OR_VALUES = 20

const CALLDATA_COST_CHUNK_BYTES = 16 * 1024
const GAS_COST_CHUNK = 5_000_000n
const STATE_OVERRIDE_SLOT_COST_CHUNK = 16
const STATE_OVERRIDE_CODE_COST_CHUNK_BYTES = 16 * 1024
const LOG_RANGE_COST_CHUNK = 1_000n
const FEE_HISTORY_COST_CHUNK = 16n
const UPSTREAM_TIMEOUT_MS = 30_000

const rateLimiter = createRateLimiter({
Expand All @@ -42,6 +61,242 @@ interface JsonRpcRequest {
id?: unknown
}

type JsonObject = Record<string, unknown>

function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function invalidParams(statusMessage: string, statusCode = 400): never {
throw createError({ statusCode, statusMessage })
}

function jsonByteLength(value: unknown): number {
try {
return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8')
}
catch {
return invalidParams('Invalid RPC params')
}
}

function hexDataByteLength(value: unknown, label: string): number {
if (typeof value !== 'string' || !/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) {
return invalidParams(`Invalid ${label}`)
}
return (value.length - 2) / 2
}

function parseHexQuantity(value: unknown, label: string): bigint {
if (typeof value !== 'string' || !/^0x[0-9a-fA-F]+$/.test(value) || value.length > 66) {
return invalidParams(`Invalid ${label}`)
}
return BigInt(value)
}

function steppedModifier(value: number, chunk: number): number {
return Math.max(0, Math.ceil(value / chunk) - 1)
}

function steppedBigIntModifier(value: bigint, chunk: bigint): number {
if (value <= chunk) return 0
return Number((value + chunk - 1n) / chunk - 1n)
}

function assessStateOverride(value: unknown): number {
if (!isJsonObject(value)) return invalidParams('Invalid RPC state override')

const accounts = Object.values(value)
if (accounts.length > MAX_STATE_OVERRIDE_ACCOUNTS) {
return invalidParams(`RPC state override exceeds ${MAX_STATE_OVERRIDE_ACCOUNTS} accounts`)
}

let slotCount = 0
let codeBytes = 0

for (const account of accounts) {
if (!isJsonObject(account)) return invalidParams('Invalid RPC state override account')

for (const key of ['state', 'stateDiff'] as const) {
const mapping = account[key]
if (mapping === undefined) continue
if (!isJsonObject(mapping)) return invalidParams(`Invalid RPC state override ${key}`)
slotCount += Object.keys(mapping).length
if (slotCount > MAX_STATE_OVERRIDE_SLOTS) {
return invalidParams(`RPC state override exceeds ${MAX_STATE_OVERRIDE_SLOTS} storage slots`)
}
}

if (account.code !== undefined) {
codeBytes += hexDataByteLength(account.code, 'RPC state override code')
if (codeBytes > MAX_STATE_OVERRIDE_CODE_BYTES) {
return invalidParams(`RPC state override code exceeds ${MAX_STATE_OVERRIDE_CODE_BYTES} bytes`)
}
}
}

return 5
+ accounts.length
+ Math.ceil(slotCount / STATE_OVERRIDE_SLOT_COST_CHUNK)
+ Math.ceil(codeBytes / STATE_OVERRIDE_CODE_COST_CHUNK_BYTES)
}

function assessTransactionParams(params: unknown): number {
if (!Array.isArray(params)) return 0
const transaction = params[0]
if (!isJsonObject(transaction)) return 0

let calldataBytes = 0
for (const key of ['data', 'input'] as const) {
if (transaction[key] === undefined) continue
calldataBytes += hexDataByteLength(transaction[key], 'RPC calldata')
}
if (calldataBytes > MAX_CALLDATA_BYTES) {
return invalidParams(`RPC calldata exceeds ${MAX_CALLDATA_BYTES} bytes`, 413)
}

let cost = steppedModifier(calldataBytes, CALLDATA_COST_CHUNK_BYTES)
if (transaction.gas !== undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — omitting gas bypasses the execution-cost charge. gas is optional for eth_call; when absent, the RPC node still executes with its configured/default call gas cap, which can be as expensive as the maximum allowed explicit gas. Here the modifier only runs when the client supplies transaction.gas, so an adversary can remove that field and reduce a potentially max-cost call to the base cost of 2. Please conservatively charge omitted gas against an appropriate default/cap (or otherwise bound the upstream execution), and add a regression test proving that removing gas cannot make the same expensive call materially cheaper.

const gas = parseHexQuantity(transaction.gas, 'RPC gas limit')
if (gas > MAX_EXPLICIT_GAS) {
return invalidParams(`RPC gas limit exceeds ${MAX_EXPLICIT_GAS}`)
}
cost += steppedBigIntModifier(gas, GAS_COST_CHUNK)
}

const stateOverride = params[2]
if (stateOverride !== undefined && stateOverride !== null) {
cost += assessStateOverride(stateOverride)
}

return cost
}

function validateLogAddress(value: unknown): boolean {
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
}

function validateLogTopic(value: unknown): boolean {
return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value)
}

function assessLogFilter(params: unknown): number {
if (!Array.isArray(params) || !isJsonObject(params[0])) {
return invalidParams('Invalid eth_getLogs filter')
}
const filter = params[0]
const hasBlockHash = filter.blockHash !== undefined
const hasFromBlock = filter.fromBlock !== undefined
const hasToBlock = filter.toBlock !== undefined

let range = 0n
if (hasBlockHash) {
if (!validateLogTopic(filter.blockHash) || hasFromBlock || hasToBlock) {
return invalidParams('eth_getLogs requires blockHash or an explicit block range')
}
}
else {
if (!hasFromBlock || !hasToBlock) {
return invalidParams('eth_getLogs requires blockHash or an explicit block range')
}
if (filter.fromBlock === 'latest' || filter.toBlock === 'latest') {
if (filter.fromBlock !== 'latest' || filter.toBlock !== 'latest') {
return invalidParams('Invalid eth_getLogs block range')
}
range = 1n
}
else {
const fromBlock = parseHexQuantity(filter.fromBlock, 'eth_getLogs fromBlock')
const toBlock = parseHexQuantity(filter.toBlock, 'eth_getLogs toBlock')
if (toBlock < fromBlock) return invalidParams('Invalid eth_getLogs block range')
range = toBlock - fromBlock + 1n
}
if (range > MAX_LOG_BLOCK_RANGE) {
return invalidParams(`eth_getLogs block range exceeds ${MAX_LOG_BLOCK_RANGE}`)
}
}

let hasRestrictiveFilter = false
if (filter.address !== undefined) {
const addresses = Array.isArray(filter.address) ? filter.address : [filter.address]
if (addresses.length === 0 || addresses.length > MAX_LOG_ADDRESSES || !addresses.every(validateLogAddress)) {
return invalidParams(`Invalid eth_getLogs address filter (maximum ${MAX_LOG_ADDRESSES})`)
}
hasRestrictiveFilter = true
}

if (filter.topics !== undefined) {
if (!Array.isArray(filter.topics) || filter.topics.length > MAX_LOG_TOPIC_POSITIONS) {
return invalidParams(`Invalid eth_getLogs topics filter (maximum ${MAX_LOG_TOPIC_POSITIONS} positions)`)
}

for (const topic of filter.topics) {
if (topic === null) continue
if (Array.isArray(topic)) {
if (topic.length === 0 || topic.length > MAX_LOG_TOPIC_OR_VALUES || !topic.every(validateLogTopic)) {
return invalidParams('Invalid eth_getLogs topic values')
}
}
else if (!validateLogTopic(topic)) {
return invalidParams('Invalid eth_getLogs topic')
}
hasRestrictiveFilter = true
}
}

if (!hasRestrictiveFilter) {
return invalidParams('eth_getLogs requires an address or topic filter')
}

return 5 + steppedBigIntModifier(range, LOG_RANGE_COST_CHUNK)
}

function assessFeeHistory(params: unknown): number {
if (!Array.isArray(params)) return invalidParams('Invalid eth_feeHistory params')
const blockCount = parseHexQuantity(params[0], 'eth_feeHistory block count')
if (blockCount === 0n || blockCount > MAX_FEE_HISTORY_BLOCKS) {
return invalidParams(`eth_feeHistory block count must be between 1 and ${MAX_FEE_HISTORY_BLOCKS}`)
}

const percentiles = params[2]
if (percentiles !== undefined && percentiles !== null) {
if (!Array.isArray(percentiles) || percentiles.length > MAX_REWARD_PERCENTILES) {
return invalidParams(`Invalid eth_feeHistory reward percentiles (maximum ${MAX_REWARD_PERCENTILES})`)
}
let previous = -1
for (const percentile of percentiles) {
if (typeof percentile !== 'number' || !Number.isFinite(percentile) || percentile < 0 || percentile > 100 || percentile <= previous) {
return invalidParams('eth_feeHistory reward percentiles must be sorted unique numbers from 0 to 100')
}
previous = percentile
}
}

return 2 + steppedBigIntModifier(blockCount, FEE_HISTORY_COST_CHUNK)
}

function assessRpcRequestCost(request: JsonRpcRequest): number {
if (jsonByteLength(request.params) > MAX_PARAMS_BYTES) {
return invalidParams(`RPC params exceed ${MAX_PARAMS_BYTES} bytes`, 413)
}

switch (request.method) {
case 'eth_call':
return 2 + assessTransactionParams(request.params)
case 'eth_estimateGas':
case 'eth_createAccessList':
return 10 + assessTransactionParams(request.params)
case 'eth_getLogs':
return assessLogFilter(request.params)
case 'eth_feeHistory':
return assessFeeHistory(request.params)
case 'eth_getBlockByNumber':
return Array.isArray(request.params) && request.params[1] === true ? 5 : 1
default:
return 1
}
}

// Validates a JSON-RPC 2.0 request object. Requires `id` to be present,
// which means JSON-RPC 2.0 *notifications* (requests without `id`) are
// intentionally rejected — the proxy only handles request/response patterns.
Expand Down Expand Up @@ -93,6 +348,7 @@ export default defineEventHandler(async (event) => {
}

const isBatch = Array.isArray(body)
let requestCost = 0

if (isBatch) {
if (body.length === 0) {
Expand All @@ -111,6 +367,7 @@ export default defineEventHandler(async (event) => {
)
throw createError({ statusCode: 403, statusMessage: `Method not allowed: ${(req as JsonRpcRequest)?.method ?? 'unknown'}` })
}
requestCost += assessRpcRequestCost(req)
}
}
else {
Expand All @@ -121,9 +378,10 @@ export default defineEventHandler(async (event) => {
)
throw createError({ statusCode: 403, statusMessage: `Method not allowed: ${body?.method ?? 'unknown'}` })
}
requestCost = assessRpcRequestCost(body)
}

rateLimiter.consume(event)
rateLimiter.consume(event, requestCost)

const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS)
Expand Down
Loading
Loading