fix(indexer): gate V2/V3 AMM handlers to canonical factory; floor parseLimit everywhere

Red re-verify found the V4 AMM-spoof gate left a parallel bypass.

MEDIUM — V2/V3 AMM handlers were UNGATED: handlePairCreated, handlePoolCreated,
handleSwapV2, handleSwapV3 accepted events from ANY emitter — a parallel path to
seed phantom pairs/pools/swaps and inflate the SAME public Factory/TVL/volume
aggregates the now-gated V4 handlers (0x9999) block. Mirror the V4 gate:
  - PairCreated/PoolCreated now require the canonical FactoryV2/FactoryV3 emitter
    (the AMM trust root), exactly as V4 requires 0x9999.
  - V2/V3 Swaps are emitted by the pair/pool, not the factory, so the gate is
    "is the emitter a pool the canonical factory created?" (isKnownPool). The
    factory authorizes a pool; only that pool's Swaps are honored. A rogue
    factory's PairCreated is rejected, so its pools are never registered, so its
    Swaps are rejected — the whole parallel path is closed.
  - Pair/pool keys lower-cased at seed so the Swap-side lookup is RPC-casing safe.
  - Factory addresses configurable (Config.FactoryV2/V3 + FACTORY_V2/V3 env),
    default to canonical Lux mainnet (LuxMainnet.Factory{V2,V3}); main wires env.

INFO — the per-namespace resolver limit lacked the `if limit < 1 { limit = 100 }`
floor that resolvers/dex has, so a negative/zero `first` flowed to ListByType
(`first:-1` = "no limit" in SQLite — an unbounded scan). Brought every pl()/
parseLimit() copy to the dex-canonical body (floor <1->100 + cap 1000); fhe and
platform also gained the previously-missing 1000 cap. All namespaces now identical.

Tests (go test ./...): indexer + resolvers/{dex,exchange,fhe,platform} green.
  - TestHandleV2_RejectsNonFactory, TestHandleV3_RejectsNonFactory: rogue
    PairCreated/PoolCreated/Swap seeds nothing and does not inflate the factory
    aggregate; the canonical-source event IS honored end-to-end.
  - TestPL_ClampsFirst / TestParseLimit_ClampsFirst (exchange/fhe/platform):
    negative/zero floored to 100, capped at 1000.
This commit is contained in:
2026-06-21 20:27:48 -07:00
parent c2bd4165e6
commit 705e38c45e
33 changed files with 410 additions and 10 deletions
+3
View File
@@ -1 +1,4 @@
# rebuild 1776010882
# Build artifacts (CI builds binaries on ARC; never commit local builds)
/graphd
+11 -2
View File
@@ -92,8 +92,17 @@ func main() {
stopReplicate := storage.StartReplicate(filepath.Join(*dataDir, "graph.db"))
defer stopReplicate()
// Indexer: subscribe to EVM events via RPC
idx := indexer.New(*rpcEndpoint, store)
// Indexer: subscribe to EVM events via RPC. The AMM trust roots (V2/V3
// factories) and the 0x9999 settlement manager default to the canonical Lux
// mainnet addresses; override per-network (e.g. Zoo) via env. Events from any
// other emitter are rejected so they cannot seed phantom pairs/pools/swaps or
// inflate the public Factory/TVL/volume aggregates.
idx := indexer.NewWithConfig(indexer.Config{
RPC: *rpcEndpoint,
FactoryV2: os.Getenv("FACTORY_V2"),
FactoryV3: os.Getenv("FACTORY_V3"),
PoolManager: os.Getenv("POOL_MANAGER"),
}, store)
go func() {
if err := idx.Run(ctx); err != nil && ctx.Err() == nil {
slog.Error("indexer", "error", err)
+156
View File
@@ -558,3 +558,159 @@ func TestPoll_MalformedLogDoesNotCrash(t *testing.T) {
t.Fatalf("pollGuarded error must identify the recovered panic, got %v", perr)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// MEDIUM: V2/V3 AMM handlers must be scoped to the canonical factory.
//
// handlePairCreated/handlePoolCreated/handleSwapV2/handleSwapV3 fed the SAME
// Factory/TVL/volume aggregates the V4 handlers now gate to 0x9999, but accepted
// events from ANY emitter — a parallel path to seed phantom pairs/pools/swaps. The
// fix gates pair/pool creation to the canonical factory addresses, and Swaps to the
// pools those canonical events created. These tests prove a rogue emitter seeds
// nothing and does not inflate the factory aggregate, while the canonical-source
// event IS honored end-to-end (creation → swap).
// ─────────────────────────────────────────────────────────────────────────────
// Canonical factories used by these tests (distinct from the rogue address). They
// are passed explicitly so the test does not depend on the package defaults.
const (
testFactoryV2 = "0x00000000000000000000000000000000000000F2"
testFactoryV3 = "0x00000000000000000000000000000000000000F3"
)
// word20InWord32 right-aligns a 20-byte address into a 32-byte ABI data word —
// the encoding handlePairCreated/handlePoolCreated read the created pair/pool
// address from (the last 20 bytes of the first data word).
func word20InWord32(addr string) string {
return fmt.Sprintf("%064s", strings.ToLower(strings.TrimPrefix(addr, "0x")))
}
// pairCreatedLog builds a V2 PairCreated whose created-pair address is `pair`.
// topics: [sig, token0, token1]; data word0 = pair (right-aligned), word1 = index.
func pairCreatedLog(emitter, token0, token1, pair string) *logEntry {
return &logEntry{
Address: emitter,
Topics: []string{SigPairCreated, addrTopic(token0), addrTopic(token1)},
Data: "0x" + word20InWord32(pair) + word(big.NewInt(1)),
BlockNumber: "0x1",
TransactionHash: "0xpair",
LogIndex: "0x0",
}
}
// poolCreatedLog builds a V3 PoolCreated whose created-pool address is `pool`.
// topics: [sig, token0, token1, fee]; data word0 = pool (right-aligned).
func poolCreatedLog(emitter, token0, token1 string, fee int64, pool string) *logEntry {
return &logEntry{
Address: emitter,
Topics: []string{SigPoolCreated, addrTopic(token0), addrTopic(token1), topic32(fmt.Sprintf("%x", fee))},
Data: "0x" + word20InWord32(pool),
BlockNumber: "0x1",
TransactionHash: "0xpool",
LogIndex: "0x0",
}
}
// swapV2Log builds a V2 Swap emitted by `emitter` (the pair contract).
func swapV2Log(emitter, sender, tx string, amt0In, amt1Out int64) *logEntry {
return &logEntry{
Address: emitter,
Topics: []string{SigSwapV2, addrTopic(sender)},
Data: "0x" + word(big.NewInt(amt0In)) + word(new(big.Int)) +
word(new(big.Int)) + word(big.NewInt(amt1Out)),
BlockNumber: "0x2", TransactionHash: tx, LogIndex: "0x0",
}
}
// swapV3Log builds a V3 Swap emitted by `emitter` (the pool contract); legs signed.
func swapV3Log(emitter, sender, tx string, amt0, amt1 int64) *logEntry {
return &logEntry{
Address: emitter,
Topics: []string{SigSwapV3, addrTopic(sender), addrTopic(sender)},
Data: "0x" + signedWord(big.NewInt(amt0)) + signedWord(big.NewInt(amt1)),
BlockNumber: "0x2", TransactionHash: tx, LogIndex: "0x0",
}
}
func TestHandleV2_RejectsNonFactory(t *testing.T) {
const rogue = "0x000000000000000000000000000000000000dEaD"
token0 := "0x0000000000000000000000000000000000000011"
token1 := "0x0000000000000000000000000000000000000022"
rt := "0x00000000000000000000000000000000000000a1" // rogue pair address
ct := "0x00000000000000000000000000000000000000b1" // canonical pair address
s := newMemSQLiteStore(t)
idx := NewWithConfig(Config{RPC: "http://unused", FactoryV2: testFactoryV2, FactoryV3: testFactoryV3}, s)
// --- Rogue PairCreated: nothing seeded, factory not inflated. ---
idx.processLog(pairCreatedLog(rogue, token0, token1, rt))
if p, _ := s.GetPool(nil, rt); p != nil {
t.Error("rogue PairCreated must NOT seed a pair")
}
if tok, _ := s.GetByType("Token", token0); tok != nil {
t.Error("rogue PairCreated must NOT seed a token")
}
if f, _ := s.GetFactory(nil, "1"); f != nil && asInt64(f.(map[string]interface{})["poolCount"]) != 0 {
t.Errorf("rogue PairCreated must NOT inflate factory poolCount, got %v", f)
}
// --- A Swap from the rogue (never-registered) pair: no swap recorded. ---
idx.processLog(swapV2Log(rt, token0, "0xspoof", 1000, 2500))
if sw, _ := s.GetSwaps(nil, 10, "timestamp", "desc", nil); sw != nil && len(sw.([]interface{})) != 0 {
t.Fatalf("Swap from a rogue V2 pair must NOT be recorded, got %d", len(sw.([]interface{})))
}
// --- Canonical PairCreated IS honored; its pair's Swap IS recorded. ---
idx.processLog(pairCreatedLog(testFactoryV2, token0, token1, ct))
if p, _ := s.GetPool(nil, ct); p == nil {
t.Fatal("canonical PairCreated must seed the pair")
}
if f, _ := s.GetFactory(nil, "1"); f == nil || asInt64(f.(map[string]interface{})["poolCount"]) != 1 {
t.Fatalf("canonical PairCreated must bump factory poolCount to 1, got %v", f)
}
idx.processLog(swapV2Log(ct, token0, "0xreal", 1000, 2500))
sw, _ := s.GetSwaps(nil, 10, "timestamp", "desc", nil)
if sw == nil || len(sw.([]interface{})) != 1 {
t.Fatalf("Swap from the canonical V2 pair must be recorded exactly once, got %v", sw)
}
}
func TestHandleV3_RejectsNonFactory(t *testing.T) {
const rogue = "0x000000000000000000000000000000000000dEaD"
token0 := "0x0000000000000000000000000000000000000011"
token1 := "0x0000000000000000000000000000000000000022"
rt := "0x00000000000000000000000000000000000000a3" // rogue pool address
ct := "0x00000000000000000000000000000000000000b3" // canonical pool address
s := newMemSQLiteStore(t)
idx := NewWithConfig(Config{RPC: "http://unused", FactoryV2: testFactoryV2, FactoryV3: testFactoryV3}, s)
// --- Rogue PoolCreated: nothing seeded, factory not inflated. ---
idx.processLog(poolCreatedLog(rogue, token0, token1, 3000, rt))
if p, _ := s.GetPool(nil, rt); p != nil {
t.Error("rogue PoolCreated must NOT seed a pool")
}
if f, _ := s.GetFactory(nil, "1"); f != nil && asInt64(f.(map[string]interface{})["poolCount"]) != 0 {
t.Errorf("rogue PoolCreated must NOT inflate factory poolCount, got %v", f)
}
// --- A Swap from the rogue (never-registered) pool: no swap recorded. ---
idx.processLog(swapV3Log(rt, token0, "0xspoof", 1000, -2500))
if sw, _ := s.GetSwaps(nil, 10, "timestamp", "desc", nil); sw != nil && len(sw.([]interface{})) != 0 {
t.Fatalf("Swap from a rogue V3 pool must NOT be recorded, got %d", len(sw.([]interface{})))
}
// --- Canonical PoolCreated IS honored; its pool's Swap IS recorded with signed legs. ---
idx.processLog(poolCreatedLog(testFactoryV3, token0, token1, 3000, ct))
if p, _ := s.GetPool(nil, ct); p == nil {
t.Fatal("canonical PoolCreated must seed the pool")
}
if f, _ := s.GetFactory(nil, "1"); f == nil || asInt64(f.(map[string]interface{})["poolCount"]) != 1 {
t.Fatalf("canonical PoolCreated must bump factory poolCount to 1, got %v", f)
}
idx.processLog(swapV3Log(ct, token0, "0xreal", 1000, 2500))
sw, _ := s.GetSwaps(nil, 10, "timestamp", "desc", nil)
if sw == nil || len(sw.([]interface{})) != 1 {
t.Fatalf("Swap from the canonical V3 pool must be recorded exactly once, got %v", sw)
}
}
+67 -6
View File
@@ -27,7 +27,8 @@ type Status struct {
}
// Config tunes an Indexer. The zero value is valid: PoolManager defaults to the
// canonical 0x9999 settlement address and StartBlock to 0 (index from genesis).
// canonical 0x9999 settlement address, FactoryV2/FactoryV3 to the canonical Lux
// mainnet AMM factories, and StartBlock to 0 (index from genesis).
type Config struct {
RPC string
// PoolManager is the DEX settlement precompile address (0x9999). Logs from
@@ -35,6 +36,15 @@ type Config struct {
// Fill/Market entities. Lower-cased on construction for case-insensitive
// compare against eth_getLogs `address` fields.
PoolManager string
// FactoryV2 is the canonical Uniswap-v2 factory. ONLY its PairCreated is
// authoritative; pairs it creates are the only valid V2 Swap emitters. A
// PairCreated from any other contract is rejected (otherwise anyone could
// seed phantom pairs and inflate the public Factory/TVL/volume aggregates).
// Lower-cased on construction.
FactoryV2 string
// FactoryV3 is the canonical Uniswap-v3 factory — same trust-root role as
// FactoryV2 for PoolCreated and V3 Swaps. Lower-cased on construction.
FactoryV3 string
// StartBlock is the genesis-relative block to (re)index from. On a clean
// chain relaunch the indexer rewinds to this value (see poll's reorg guard).
StartBlock uint64
@@ -70,6 +80,8 @@ const genesisBlockTag = "0x0"
type Indexer struct {
rpc string
poolManager string // lower-cased 0x9999 settlement address
factoryV2 string // lower-cased canonical V2 factory (PairCreated trust root)
factoryV3 string // lower-cased canonical V3 factory (PoolCreated trust root)
startBlock uint64
store *storage.Store
client *http.Client
@@ -98,9 +110,19 @@ func NewWithConfig(cfg Config, store *storage.Store) *Indexer {
if pm == "" {
pm = LXSettleAddress
}
fv2 := cfg.FactoryV2
if fv2 == "" {
fv2 = LuxMainnet.FactoryV2
}
fv3 := cfg.FactoryV3
if fv3 == "" {
fv3 = LuxMainnet.FactoryV3
}
idx := &Indexer{
rpc: cfg.RPC,
poolManager: strings.ToLower(pm),
factoryV2: strings.ToLower(fv2),
factoryV3: strings.ToLower(fv3),
startBlock: cfg.StartBlock,
store: store,
client: &http.Client{
@@ -535,6 +557,14 @@ func topicAddr(topic string) string {
}
func (idx *Indexer) handleSwapV2(l *logEntry, blockNum uint64, txHash, logIdx string) {
// A V2 Swap is emitted by the pair contract, NOT the factory — so the gate is
// "is the emitter a pair the canonical V2 factory created?". handlePairCreated
// (gated to factoryV2) is the ONLY path that registers a pair, so a Swap from a
// rogue pair was never registered and is dropped here. This closes the parallel
// bypass into the same SeedSwap/factory volume aggregates the V4 path now gates.
if !idx.isKnownPool(l.Address) {
return
}
id := fmt.Sprintf("%s#%s", txHash, logIdx)
sender := ""
if len(l.Topics) > 1 {
@@ -560,6 +590,12 @@ func (idx *Indexer) handleSwapV2(l *logEntry, blockNum uint64, txHash, logIdx st
}
func (idx *Indexer) handleSwapV3(l *logEntry, blockNum uint64, txHash, logIdx string) {
// Same trust model as handleSwapV2: a V3 Swap is emitted by the pool, which is
// only registered via handlePoolCreated (gated to factoryV3). A Swap from an
// unregistered (rogue) pool is dropped.
if !idx.isKnownPool(l.Address) {
return
}
id := fmt.Sprintf("%s#%s", txHash, logIdx)
sender := ""
if len(l.Topics) > 1 {
@@ -579,7 +615,12 @@ func (idx *Indexer) handleSwapV3(l *logEntry, blockNum uint64, txHash, logIdx st
}
func (idx *Indexer) handlePairCreated(l *logEntry) {
if len(l.Topics) < 3 {
// Scope to the canonical V2 factory (the trust root), exactly as the V4
// handlers scope to 0x9999. A PairCreated from any other contract is rejected:
// honoring it would let anyone seed phantom pairs/tokens and inflate the
// explorer's public Factory/TVL/volume aggregates — and would register a rogue
// pair address whose Swaps would then be accepted (the parallel bypass).
if len(l.Topics) < 3 || !strings.EqualFold(l.Address, idx.factoryV2) {
return
}
data := strings.TrimPrefix(l.Data, "0x")
@@ -589,7 +630,9 @@ func (idx *Indexer) handlePairCreated(l *logEntry) {
token0 := topicAddr(l.Topics[1])
token1 := topicAddr(l.Topics[2])
pair := "0x" + data[:64]
pair = topicAddr("0x" + pair[len(pair)-40:])
// Lower-case the pair key so isKnownPool's Swap-side lookup matches regardless
// of how the RPC backend cased the emitter address (the V2 Swap's l.Address).
pair = strings.ToLower(topicAddr("0x" + pair[len(pair)-40:]))
idx.store.SeedPool(pair, &storage.SeedPoolData{
Token0: token0,
@@ -602,7 +645,11 @@ func (idx *Indexer) handlePairCreated(l *logEntry) {
}
func (idx *Indexer) handlePoolCreated(l *logEntry) {
if len(l.Topics) < 4 {
// Scope to the canonical V3 factory (the trust root), mirroring handlePairCreated
// and the V4 0x9999 gate. A PoolCreated from a rogue contract is rejected so it
// can neither inflate the Factory/TVL aggregates nor register a rogue pool whose
// Swaps would be honored.
if len(l.Topics) < 4 || !strings.EqualFold(l.Address, idx.factoryV3) {
return
}
data := strings.TrimPrefix(l.Data, "0x")
@@ -614,8 +661,9 @@ func (idx *Indexer) handlePoolCreated(l *logEntry) {
feeHex := strings.TrimPrefix(l.Topics[3], "0x")
fee, _ := strconv.ParseInt(feeHex, 16, 64)
// Pool address is in data (last 20 bytes of first 32-byte word if padded, or second word)
pool := topicAddr("0x" + strings.TrimPrefix(l.Data, "0x"))
// Pool address is in data (last 20 bytes of first 32-byte word if padded, or second word).
// Lower-cased so isKnownPool's V3 Swap-side lookup matches the emitter regardless of RPC casing.
pool := strings.ToLower(topicAddr("0x" + strings.TrimPrefix(l.Data, "0x")))
idx.store.SeedPool(pool, &storage.SeedPoolData{
Token0: token0,
@@ -834,6 +882,19 @@ func (idx *Indexer) isPoolManager(addr string) bool {
return strings.EqualFold(addr, idx.poolManager)
}
// isKnownPool reports whether a log address is a V2 pair / V3 pool that the
// canonical factory created — i.e. one we persisted via handlePairCreated /
// handlePoolCreated (both gated to the canonical factory). It is the Swap-side
// half of the AMM trust root: the factory authorizes a pool, and only that pool's
// Swaps are honored. eth_getLogs returns logs in block+logIndex order, so the
// PairCreated/PoolCreated (in an earlier or the same block) is always processed
// before the pool's first Swap. Pairs are seeded lower-cased (topicAddr), so the
// lookup is case-insensitive.
func (idx *Indexer) isKnownPool(addr string) bool {
p, _ := idx.store.GetPool(nil, strings.ToLower(addr))
return p != nil
}
// shortAddr returns a stable short symbol for an unknown token address.
func shortAddr(addr string) string {
if len(addr) >= 8 {
+3
View File
@@ -82,5 +82,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -71,5 +71,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -60,5 +60,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -60,5 +60,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -60,5 +60,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -89,5 +89,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+31
View File
@@ -0,0 +1,31 @@
package exchange
import "testing"
// LOW (query-DoS): the resolver limit must clamp `first` exactly like the dex
// namespace — default 100, floor <1→100, hard cap 1000. Before the fix this
// namespace's pl() capped at 1000 but had NO floor, so a negative/zero `first`
// flowed straight to ListByType (and `first:-1` is "no limit" in SQLite — an
// unbounded scan). This proves the floor now matches dex.
func TestPL_ClampsFirst(t *testing.T) {
cases := []struct {
name string
args map[string]interface{}
want int
}{
{"absent defaults to 100", map[string]interface{}{}, 100},
{"in range honored", map[string]interface{}{"first": 250}, 250},
{"over cap clamped to 1000", map[string]interface{}{"first": 1_000_000}, 1000},
{"exactly cap honored", map[string]interface{}{"first": 1000}, 1000},
{"zero floors to 100", map[string]interface{}{"first": 0}, 100},
{"negative floors to 100", map[string]interface{}{"first": -1}, 100},
{"unparseable floors to 100", map[string]interface{}{"first": "not-a-number"}, 100},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := pl(tc.args); got != tc.want {
t.Errorf("pl(%v) = %d, want %d", tc.args, got, tc.want)
}
})
}
}
+4 -1
View File
@@ -114,5 +114,8 @@ func parseLimit(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
return limit
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+31
View File
@@ -0,0 +1,31 @@
package fhe
import "testing"
// LOW (query-DoS): parseLimit must clamp `first` exactly like the dex namespace —
// default 100, floor <1→100, hard cap 1000. Before the fix this namespace's
// parseLimit had NEITHER a floor NOR a cap (`return limit`), so any `first` flowed
// straight to ListByType — an unbounded scan (and `first:-1` = "no limit" in
// SQLite). This proves both the floor and the cap now match dex.
func TestParseLimit_ClampsFirst(t *testing.T) {
cases := []struct {
name string
args map[string]interface{}
want int
}{
{"absent defaults to 100", map[string]interface{}{}, 100},
{"in range honored", map[string]interface{}{"first": 250}, 250},
{"over cap clamped to 1000", map[string]interface{}{"first": 1_000_000}, 1000},
{"exactly cap honored", map[string]interface{}{"first": 1000}, 1000},
{"zero floors to 100", map[string]interface{}{"first": 0}, 100},
{"negative floors to 100", map[string]interface{}{"first": -1}, 100},
{"unparseable floors to 100", map[string]interface{}{"first": "not-a-number"}, 100},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseLimit(tc.args); got != tc.want {
t.Errorf("parseLimit(%v) = %d, want %d", tc.args, got, tc.want)
}
})
}
}
+3
View File
@@ -60,5 +60,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -82,5 +82,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -82,5 +82,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -71,5 +71,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -62,5 +62,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
@@ -62,5 +62,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -83,5 +83,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -82,5 +82,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+4 -1
View File
@@ -84,5 +84,8 @@ func parseLimit(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
return limit
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+31
View File
@@ -0,0 +1,31 @@
package platform
import "testing"
// LOW (query-DoS): parseLimit must clamp `first` exactly like the dex namespace —
// default 100, floor <1→100, hard cap 1000. Before the fix this namespace's
// parseLimit had NEITHER a floor NOR a cap (`return limit`), so any `first` flowed
// straight to ListByType — an unbounded scan (and `first:-1` = "no limit" in
// SQLite). This proves both the floor and the cap now match dex.
func TestParseLimit_ClampsFirst(t *testing.T) {
cases := []struct {
name string
args map[string]interface{}
want int
}{
{"absent defaults to 100", map[string]interface{}{}, 100},
{"in range honored", map[string]interface{}{"first": 250}, 250},
{"over cap clamped to 1000", map[string]interface{}{"first": 1_000_000}, 1000},
{"exactly cap honored", map[string]interface{}{"first": 1000}, 1000},
{"zero floors to 100", map[string]interface{}{"first": 0}, 100},
{"negative floors to 100", map[string]interface{}{"first": -1}, 100},
{"unparseable floors to 100", map[string]interface{}{"first": "not-a-number"}, 100},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseLimit(tc.args); got != tc.want {
t.Errorf("parseLimit(%v) = %d, want %d", tc.args, got, tc.want)
}
})
}
}
+3
View File
@@ -155,5 +155,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -60,5 +60,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -78,5 +78,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -82,5 +82,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -82,5 +82,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -146,5 +146,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -82,5 +82,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -71,5 +71,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -67,5 +67,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}
+3
View File
@@ -79,5 +79,8 @@ func pl(args map[string]interface{}) int {
if l, ok := args["first"]; ok {
fmt.Sscanf(fmt.Sprint(l), "%d", &limit)
}
if limit < 1 {
limit = 100
}
return min(limit, 1000)
}