Skip to content

Commit a4a328c

Browse files
authored
fix(blocks): bound public block pagination offset (#9)
1 parent 2437158 commit a4a328c

4 files changed

Lines changed: 25 additions & 6 deletions

File tree

server/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import path from 'path'
1111
import fs from 'fs'
1212
import { fileURLToPath } from 'url'
1313
import { blockCache } from './lib/cache'
14-
import { handleRouteError, parseNetwork, parseLimit, parseOffset, ValidationError } from './lib/http'
14+
import { handleRouteError, parseNetwork, parseLimit, parseOffset, parseBlockOffset, ValidationError } from './lib/http'
1515
import { computeCirculatingSupply, currentBlockReward } from './lib/supply'
1616
import { rpcWithNetwork } from '@fairco.in/rpc-client'
1717
import priceRouter from './routes/price'
@@ -122,7 +122,7 @@ app.get('/api/blocks', async (req, res) => {
122122
try {
123123
const network = parseNetwork(req.query.network)
124124
const limit = parseLimit(req.query.limit)
125-
const offset = parseOffset(req.query.offset)
125+
const offset = parseBlockOffset(req.query.offset)
126126
const [blocks, height] = await Promise.all([
127127
blockCache.getRecentBlocks(network, limit, offset),
128128
blockCache.getBlockCount(network),

server/lib/cache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { MongoClient, Db } from 'mongodb'
22
import { rpcWithNetwork, type NetworkType, type RpcParam } from '@fairco.in/rpc-client'
3-
import { escapeRegex } from './http'
3+
import { escapeRegex, MAX_BLOCK_OFFSET } from './http'
44

55
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/faircoin-explorer'
66

@@ -639,7 +639,7 @@ export class BlockCache extends BlockchainCache {
639639
// Get recent blocks with caching
640640
async getRecentBlocks(network: NetworkType, limit: number = 20, offset: number = 0): Promise<any[]> {
641641
assertValidNetwork(network)
642-
const safeOffset = Math.max(0, Math.floor(offset))
642+
const safeOffset = Math.min(MAX_BLOCK_OFFSET, Math.max(0, Math.floor(offset)))
643643
const safeLimit = Math.max(1, Math.floor(limit))
644644
const db = await this.getDb()
645645
const collection = db.collection('recent_blocks')

server/lib/http.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import {
33
parseNetwork,
44
parseLimit,
55
parseOffset,
6+
parseBlockOffset,
67
parseAddress,
78
escapeRegex,
89
ValidationError,
910
MIN_LIMIT,
1011
MAX_LIMIT,
12+
MAX_BLOCK_OFFSET,
1113
} from './http'
1214

1315
describe('parseNetwork', () => {
@@ -54,6 +56,14 @@ describe('parseOffset', () => {
5456
})
5557
})
5658

59+
describe('parseBlockOffset', () => {
60+
it('bounds public block pagination offsets', () => {
61+
expect(parseBlockOffset('-10')).toBe(0)
62+
expect(parseBlockOffset('25')).toBe(25)
63+
expect(parseBlockOffset(String(MAX_BLOCK_OFFSET + 1))).toBe(MAX_BLOCK_OFFSET)
64+
})
65+
})
66+
5767
describe('parseAddress', () => {
5868
// A 34-char base58 address (valid shape).
5969
const validAddress = 'fVALIDADDRESSEEEEEEEEEEEEEEEEEEEEEE'.replace(/0|O|I|l/g, 'a')

server/lib/http.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { NetworkType } from '@fairco.in/rpc-client'
1616
/** Inclusive bounds for any list `limit` query parameter. */
1717
export const MIN_LIMIT = 1
1818
export const MAX_LIMIT = 100
19+
export const MAX_BLOCK_OFFSET = 10_000
1920
const DEFAULT_LIMIT = 20
2021

2122
const NETWORKS = ['mainnet', 'testnet'] as const
@@ -69,8 +70,7 @@ export function parseLimit(value: unknown): number {
6970
}
7071

7172
/**
72-
* Coerce the `offset` query parameter into a non-negative integer (no upper
73-
* bound — callers may legitimately page far back into the chain). Returns 0
73+
* Coerce the `offset` query parameter into a non-negative integer. Returns 0
7474
* when absent or non-numeric; clamps negatives to 0.
7575
*/
7676
export function parseOffset(value: unknown): number {
@@ -80,6 +80,15 @@ export function parseOffset(value: unknown): number {
8080
return Math.max(0, result.data)
8181
}
8282

83+
/**
84+
* Coerce the public block-list `offset` into a bounded range. Block windows are
85+
* expensive to materialize on cache misses, so keeping this edge-bounded limits
86+
* attacker-controlled recent-block cache keys and RPC walks.
87+
*/
88+
export function parseBlockOffset(value: unknown): number {
89+
return Math.min(MAX_BLOCK_OFFSET, parseOffset(value))
90+
}
91+
8392
/** Escape a string for safe interpolation into a MongoDB `$regex` pattern. */
8493
export function escapeRegex(value: string): string {
8594
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

0 commit comments

Comments
 (0)