Skip to content

Commit 6a0133d

Browse files
hyperpolymathclaude
andcommitted
fix: prevent atom table exhaustion in temporal_index
Replace String.to_atom with String.to_existing_atom for all lookup operations. Only create_index (bounded by @max_indexes cap) uses String.to_atom. Prevents DoS via unbounded atom creation from user-supplied db_id/series_id values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e3b4262 commit 6a0133d

1 file changed

Lines changed: 288 additions & 0 deletions

File tree

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
defmodule LithHttp.TemporalIndex do
3+
@moduledoc """
4+
B-tree temporal index for efficient time-series queries.
5+
6+
Provides O(log n + k) query performance where k = number of results.
7+
Uses ETS with ordered_set for range queries.
8+
9+
Features:
10+
- Timestamp-based indexing (Unix seconds)
11+
- Fast range queries
12+
- Per-series indexes
13+
- Automatic index updates
14+
15+
Based on B-tree algorithm with ETS ordered_set.
16+
"""
17+
18+
use GenServer
19+
require Logger
20+
21+
@table_prefix :temporal_index_
22+
23+
# Client API
24+
25+
def start_link(_opts) do
26+
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
27+
end
28+
29+
@doc """
30+
Create a temporal index for a database + series.
31+
"""
32+
def create_index(db_id, series_id) do
33+
GenServer.call(__MODULE__, {:create_index, db_id, series_id})
34+
end
35+
36+
@doc """
37+
Insert a time-series point into the index.
38+
"""
39+
def insert(db_id, series_id, point_id, timestamp_unix) do
40+
GenServer.call(__MODULE__, {:insert, db_id, series_id, point_id, timestamp_unix})
41+
end
42+
43+
@doc """
44+
Query points in a time range.
45+
Returns list of point IDs in chronological order.
46+
"""
47+
def range_query(db_id, series_id, start_unix, end_unix, limit \\ 1000) do
48+
GenServer.call(__MODULE__, {:range_query, db_id, series_id, start_unix, end_unix, limit})
49+
end
50+
51+
@doc """
52+
Delete a point from the index.
53+
"""
54+
def delete(db_id, series_id, point_id, timestamp_unix) do
55+
GenServer.call(__MODULE__, {:delete, db_id, series_id, point_id, timestamp_unix})
56+
end
57+
58+
@doc """
59+
Drop the temporal index for a database + series.
60+
"""
61+
def drop_index(db_id, series_id) do
62+
GenServer.call(__MODULE__, {:drop_index, db_id, series_id})
63+
end
64+
65+
@doc """
66+
Get index statistics (count, time range).
67+
"""
68+
def stats(db_id, series_id) do
69+
GenServer.call(__MODULE__, {:stats, db_id, series_id})
70+
end
71+
72+
# Server callbacks
73+
74+
@impl true
75+
def init(:ok) do
76+
Logger.info("Temporal index manager started")
77+
{:ok, %{indexes: %{}}}
78+
end
79+
80+
@impl true
81+
def handle_call({:create_index, db_id, series_id}, _from, state) do
82+
if map_size(state.indexes) >= @max_indexes do
83+
{:reply, {:error, :max_indexes_reached}, state}
84+
else
85+
# create_table_name_atom/2 is the ONLY path that calls String.to_atom/1,
86+
# and it is guarded by the @max_indexes check above.
87+
tbl = create_table_name_atom(db_id, series_id)
88+
89+
case :ets.info(tbl) do
90+
:undefined ->
91+
# Create ordered_set table for efficient range queries
92+
:ets.new(tbl, [:ordered_set, :named_table, :public, {:write_concurrency, true}])
93+
new_indexes = Map.put(state.indexes, {db_id, series_id}, tbl)
94+
{:reply, :ok, %{state | indexes: new_indexes}}
95+
96+
_ ->
97+
{:reply, {:error, :index_already_exists}, state}
98+
end
99+
end
100+
end
101+
102+
@impl true
103+
def handle_call({:insert, db_id, series_id, point_id, timestamp_unix}, _from, state) do
104+
case table_name(db_id, series_id) do
105+
:error ->
106+
{:reply, {:error, :index_not_found}, state}
107+
108+
{:ok, tbl} ->
109+
case :ets.info(tbl) do
110+
:undefined ->
111+
{:reply, {:error, :index_not_found}, state}
112+
113+
_ ->
114+
# Insert with composite key: {timestamp, point_id}
115+
# This ensures uniqueness and chronological ordering
116+
:ets.insert(tbl, {{timestamp_unix, point_id}, %{
117+
point_id: point_id,
118+
timestamp_unix: timestamp_unix,
119+
indexed_at: System.system_time(:second)
120+
}})
121+
{:reply, :ok, state}
122+
end
123+
end
124+
end
125+
126+
@impl true
127+
def handle_call({:range_query, db_id, series_id, start_unix, end_unix, limit}, _from, state) do
128+
case table_name(db_id, series_id) do
129+
:error ->
130+
{:reply, {:error, :index_not_found}, state}
131+
132+
{:ok, tbl} ->
133+
case :ets.info(tbl) do
134+
:undefined ->
135+
{:reply, {:error, :index_not_found}, state}
136+
137+
_ ->
138+
# Use ETS ordered_set range select
139+
results = range_select(tbl, start_unix, end_unix, limit)
140+
{:reply, {:ok, results}, state}
141+
end
142+
end
143+
end
144+
145+
@impl true
146+
def handle_call({:delete, db_id, series_id, point_id, timestamp_unix}, _from, state) do
147+
case table_name(db_id, series_id) do
148+
:error ->
149+
{:reply, {:error, :index_not_found}, state}
150+
151+
{:ok, tbl} ->
152+
case :ets.info(tbl) do
153+
:undefined ->
154+
{:reply, {:error, :index_not_found}, state}
155+
156+
_ ->
157+
:ets.delete(tbl, {timestamp_unix, point_id})
158+
{:reply, :ok, state}
159+
end
160+
end
161+
end
162+
163+
@impl true
164+
def handle_call({:drop_index, db_id, series_id}, _from, state) do
165+
case table_name(db_id, series_id) do
166+
:error ->
167+
{:reply, {:error, :index_not_found}, state}
168+
169+
{:ok, tbl} ->
170+
case :ets.info(tbl) do
171+
:undefined ->
172+
{:reply, {:error, :index_not_found}, state}
173+
174+
_ ->
175+
:ets.delete(tbl)
176+
new_indexes = Map.delete(state.indexes, {db_id, series_id})
177+
{:reply, :ok, %{state | indexes: new_indexes}}
178+
end
179+
end
180+
end
181+
182+
@impl true
183+
def handle_call({:stats, db_id, series_id}, _from, state) do
184+
case table_name(db_id, series_id) do
185+
:error ->
186+
{:reply, {:error, :index_not_found}, state}
187+
188+
{:ok, tbl} ->
189+
case :ets.info(tbl) do
190+
:undefined ->
191+
{:reply, {:error, :index_not_found}, state}
192+
193+
_ ->
194+
count = :ets.info(tbl, :size)
195+
196+
{min_ts, max_ts} =
197+
case {:ets.first(tbl), :ets.last(tbl)} do
198+
{:"$end_of_table", _} -> {nil, nil}
199+
{_, :"$end_of_table"} -> {nil, nil}
200+
{{first_ts, _}, {last_ts, _}} -> {first_ts, last_ts}
201+
end
202+
203+
stats = %{
204+
count: count,
205+
min_timestamp: min_ts,
206+
max_timestamp: max_ts,
207+
time_range_seconds: if(min_ts && max_ts, do: max_ts - min_ts, else: 0)
208+
}
209+
210+
{:reply, {:ok, stats}, state}
211+
end
212+
end
213+
end
214+
215+
# Maximum number of temporal indexes to prevent atom table exhaustion.
216+
# Each index creates one atom for the ETS named table.
217+
@max_indexes 10_000
218+
219+
# Private functions
220+
221+
@doc false
222+
# Build table name string from db_id and series_id (always safe, no atom creation).
223+
defp table_name_string(db_id, series_id) do
224+
db_hash = :crypto.hash(:md5, db_id) |> Base.encode16(case: :lower) |> String.slice(0..7)
225+
series_hash = :crypto.hash(:md5, series_id) |> Base.encode16(case: :lower) |> String.slice(0..7)
226+
227+
"#{@table_prefix}#{db_hash}_#{series_hash}"
228+
end
229+
230+
# Create a new atom for an ETS table name. Only called from create_index/2
231+
# after verifying the index count is below @max_indexes.
232+
# This is the ONLY place where String.to_atom/1 is permitted.
233+
defp create_table_name_atom(db_id, series_id) do
234+
table_name_string(db_id, series_id) |> String.to_atom()
235+
end
236+
237+
# Look up an existing table name atom. Used by all operations except create_index.
238+
# Returns {:ok, atom} if the atom already exists, or :error if it does not.
239+
# Uses String.to_existing_atom/1 so user input can never exhaust the atom table.
240+
defp table_name(db_id, series_id) do
241+
name_str = table_name_string(db_id, series_id)
242+
243+
try do
244+
{:ok, String.to_existing_atom(name_str)}
245+
rescue
246+
ArgumentError -> :error
247+
end
248+
end
249+
250+
defp range_select(table_name, start_unix, end_unix, limit) do
251+
# ETS ordered_set allows efficient range iteration
252+
# Start from first key >= start_unix
253+
range_select_loop(table_name, {start_unix, ""}, end_unix, limit, [])
254+
end
255+
256+
defp range_select_loop(_table, _current_key, _end_unix, 0, acc) do
257+
# Limit reached
258+
Enum.reverse(acc)
259+
end
260+
261+
defp range_select_loop(table_name, current_key, end_unix, remaining, acc) do
262+
# Find next key >= current_key
263+
case find_next_key(table_name, current_key) do
264+
:"$end_of_table" ->
265+
Enum.reverse(acc)
266+
267+
{timestamp_unix, point_id} = next_key ->
268+
if timestamp_unix > end_unix do
269+
# Exceeded range
270+
Enum.reverse(acc)
271+
else
272+
# Add to results
273+
range_select_loop(table_name, next_key, end_unix, remaining - 1, [point_id | acc])
274+
end
275+
end
276+
end
277+
278+
defp find_next_key(table_name, {target_ts, _target_id}) do
279+
# Find smallest key >= {target_ts, _target_id}
280+
case :ets.next(table_name, {target_ts - 1, "~"}) do
281+
:"$end_of_table" ->
282+
:"$end_of_table"
283+
284+
key ->
285+
key
286+
end
287+
end
288+
end

0 commit comments

Comments
 (0)