fix: CSM-001 done

This commit is contained in:
2026-05-06 19:33:54 +02:00
parent 3ce6010b87
commit d3f45ba0dd
16 changed files with 217 additions and 60 deletions

View File

@@ -18,18 +18,22 @@
## Critical (Fix Immediately) ## Critical (Fix Immediately)
### CSM-001 — Atom Table Exhaustion Vulnerability ### ~~CSM-001 — Atom Table Exhaustion Vulnerability~~ ✅ FIXED
- **What:** `String.to_atom/1` on user-controlled data creates atoms that are never GC'd. A malicious payload can exhaust the ~1M atom limit and crash the VM. - **Fixed:** 2026-05-06
- **Affected files (all must be fixed):** - **What was done:**
- `lib/bds/import_definitions.ex:101``atomize_keys/1` converts all JSON keys from import analysis data - Added `BDS.MapUtils.safe_atomize_key/1` and `BDS.MapUtils.safe_atomize_keys/1` — uses `String.to_existing_atom/1` with rescue fallback to keep unknown keys as strings.
- `lib/bds/import_execution.ex:541``normalize_report/1` converts import execution report keys - Replaced all 6 affected `String.to_atom` call sites:
- `lib/bds/ai/catalog.ex:215``Enum.into(map, %{}, fn {k,v} -> {String.to_atom(k), v})` on catalog data - `lib/bds/import_definitions.ex``atomize_keys/1``MapUtils.safe_atomize_keys/1`
- `lib/bds/ai/catalog.ex:316``parse_modality/1` on provider data - `lib/bds/import_execution.ex``normalize_report/1``MapUtils.safe_atomize_keys/1`
- `lib/bds/ai/chat_tools.ex:912``maybe_put(acc, String.to_atom(key), arguments[key])` on tool arguments - `lib/bds/ai/catalog.ex``atomize_map_keys/1``MapUtils.safe_atomize_keys/1`, `parse_modality/1``MapUtils.safe_atomize_key/1`
- `lib/bds/desktop/automation.ex:380``normalized_key` on automation event data - `lib/bds/ai/chat_tools.ex``metadata_attrs/2``MapUtils.safe_atomize_key/1`
- **NOT affected:** `lib/bds/bounded_atoms.ex` uses safe string-only matching, no `String.to_atom`. `lib/bds/ui/menu_bar.ex:124,134` and `lib/bds/ui/workbench.ex:270` convert internal IDs (controlled vocabulary), which is lower risk but should still use `String.to_existing_atom/1`. - `lib/bds/desktop/automation.ex``atomize_map/1``MapUtils.safe_atomize_keys/1`
- **Fix:** Replace all `String.to_atom(key)` with `String.to_existing_atom(key)` or keep keys as strings. For the import/analysis results, keep keys as strings since the consumer code can use `Map.get(map, "key")` or `Access.key/2`. For internal IDs (menu_bar, workbench), ensure the atoms exist in the module attribute allow-lists before conversion. - Replaced lower-risk `String.to_atom` with `String.to_existing_atom/1`:
- **Test:** For each file: create a payload with 100k unique string keys; verify atom count (`:erlang.system_info(:atom_count)`) does not increase. - `lib/bds/ui/menu_bar.ex` — sidebar view and singleton editor command IDs
- `lib/bds/ui/workbench.ex``normalize_type/1`
- `lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex``map_value/3`
- `lib/bds/release_packaging.ex``normalize_platform/1`
- Updated `test/bds/bounded_atoms_test.exs` to enforce no `String.to_atom` on dynamic data (replaced old `String.to_existing_atom` ban).
--- ---
@@ -389,9 +393,10 @@
## Checklist for Agents Picking Up This File ## Checklist for Agents Picking Up This File
- [ ] All critical items (CSM-001 to CSM-005) have been addressed or explicitly deferred with justification. - [x] All critical items (CSM-001 to CSM-005) have been addressed or explicitly deferred with justification.
- CSM-001: Fixed. All `String.to_atom` on dynamic data replaced with `MapUtils.safe_atomize_key/keys` or `String.to_existing_atom`.
- [ ] All high-severity items (CSM-006 to CSM-010) have been addressed. - [ ] All high-severity items (CSM-006 to CSM-010) have been addressed.
- [ ] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`. - [x] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`.
- [ ] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations). - [ ] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations).
- [ ] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries). - [ ] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
- [ ] Tests were written **before** implementation changes (Red → Green → Refactor). - [ ] Tests were written **before** implementation changes (Red → Green → Refactor).

View File

@@ -15,6 +15,7 @@ defmodule BDS.AI.Catalog do
alias BDS.AI.Model alias BDS.AI.Model
alias BDS.AI.ModelModality alias BDS.AI.ModelModality
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.MapUtils
alias BDS.Persistence alias BDS.Persistence
alias BDS.Repo alias BDS.Repo
@@ -211,9 +212,7 @@ defmodule BDS.AI.Catalog do
end end
end end
defp atomize_map_keys(map) do defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
end
defp persist_catalog(payload) do defp persist_catalog(payload) do
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -313,7 +312,7 @@ defmodule BDS.AI.Catalog do
defp parse_modality("audio"), do: :audio defp parse_modality("audio"), do: :audio
defp parse_modality("file"), do: :file defp parse_modality("file"), do: :file
defp parse_modality("tool"), do: :tool defp parse_modality("tool"), do: :tool
defp parse_modality(other) when is_binary(other), do: String.to_atom(other) defp parse_modality(other) when is_binary(other), do: MapUtils.safe_atomize_key(other)
defp encode_nullable(nil), do: nil defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value) defp encode_nullable(value), do: Jason.encode!(value)

View File

@@ -909,7 +909,7 @@ defmodule BDS.AI.ChatTools do
defp metadata_attrs(arguments, keys) do defp metadata_attrs(arguments, keys) do
Enum.reduce(keys, %{}, fn key, acc -> Enum.reduce(keys, %{}, fn key, acc ->
maybe_put(acc, String.to_atom(key), arguments[key]) maybe_put(acc, BDS.MapUtils.safe_atomize_key(key), arguments[key])
end) end)
end end

View File

@@ -375,13 +375,7 @@ defmodule BDS.Desktop.Automation do
defp normalize_simple_reply("ok"), do: :ok defp normalize_simple_reply("ok"), do: :ok
defp normalize_simple_reply(reply), do: reply defp normalize_simple_reply(reply), do: reply
defp atomize_map(map) when is_map(map) do defp atomize_map(map) when is_map(map), do: BDS.MapUtils.safe_atomize_keys(map)
Enum.into(map, %{}, fn {key, value} ->
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
normalized_value = if is_map(value), do: atomize_map(value), else: value
{normalized_key, normalized_value}
end)
end
defp project_root do defp project_root do
Path.expand("../../..", __DIR__) Path.expand("../../..", __DIR__)

View File

@@ -287,7 +287,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp map_value(map, key, default \\ nil) defp map_value(map, key, default \\ nil)
defp map_value(map, key, default) when is_map(map) and is_binary(key) do defp map_value(map, key, default) when is_map(map) and is_binary(key) do
Map.get(map, key, Map.get(map, String.to_atom(key), default)) Map.get(map, key, Map.get(map, String.to_existing_atom(key), default))
rescue rescue
ArgumentError -> Map.get(map, key, default) ArgumentError -> Map.get(map, key, default)
end end

View File

@@ -91,19 +91,11 @@ defmodule BDS.ImportDefinitions do
defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value) defp maybe_put(map, key, value), do: Map.put(map, key, value)
alias BDS.MapUtils
defp normalize_analysis_result(nil), do: nil defp normalize_analysis_result(nil), do: nil
defp normalize_analysis_result(value) when is_binary(value), do: value defp normalize_analysis_result(value) when is_binary(value), do: value
defp normalize_analysis_result(value), do: Jason.encode!(value) defp normalize_analysis_result(value), do: Jason.encode!(value)
defp atomize_keys(value) when is_map(value) do defp atomize_keys(value), do: MapUtils.safe_atomize_keys(value)
value
|> Enum.map(fn {key, nested_value} ->
normalized_key = if(is_binary(key), do: String.to_atom(key), else: key)
{normalized_key, atomize_keys(nested_value)}
end)
|> Map.new()
end
defp atomize_keys(value) when is_list(value), do: Enum.map(value, &atomize_keys/1)
defp atomize_keys(value), do: value
end end

View File

@@ -6,6 +6,7 @@ defmodule BDS.ImportExecution do
alias BDS.Posts alias BDS.Posts
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Repo alias BDS.Repo
alias BDS.MapUtils
alias BDS.Tags alias BDS.Tags
def execute_import(project_id, report, opts \\ []) def execute_import(project_id, report, opts \\ [])
@@ -535,17 +536,7 @@ defmodule BDS.ImportExecution do
defp item_identity(%{item_type: "media", filename: filename}), do: {:media, filename} defp item_identity(%{item_type: "media", filename: filename}), do: {:media, filename}
defp item_identity(%{item_type: item_type, slug: slug}), do: {item_type, slug} defp item_identity(%{item_type: item_type, slug: slug}), do: {item_type, slug}
defp normalize_report(report) when is_map(report) do defp normalize_report(report), do: MapUtils.safe_atomize_keys(report)
report
|> Enum.map(fn {key, value} ->
normalized_key = if(is_binary(key), do: String.to_atom(key), else: key)
{normalized_key, normalize_report(value)}
end)
|> Map.new()
end
defp normalize_report(report) when is_list(report), do: Enum.map(report, &normalize_report/1)
defp normalize_report(report), do: report
defp normalize_item(item) do defp normalize_item(item) do
normalize_report(item) normalize_report(item)

View File

@@ -30,4 +30,25 @@ defmodule BDS.MapUtils do
def blank_to_nil(nil), do: nil def blank_to_nil(nil), do: nil
def blank_to_nil(""), do: nil def blank_to_nil(""), do: nil
def blank_to_nil(value), do: value def blank_to_nil(value), do: value
@spec safe_atomize_key(atom() | String.t()) :: atom() | String.t()
def safe_atomize_key(key) when is_atom(key), do: key
def safe_atomize_key(key) when is_binary(key) do
String.to_existing_atom(key)
rescue
ArgumentError -> key
end
@spec safe_atomize_keys(term()) :: term()
def safe_atomize_keys(value) when is_map(value) do
value
|> Enum.map(fn {key, nested_value} ->
{safe_atomize_key(key), safe_atomize_keys(nested_value)}
end)
|> Map.new()
end
def safe_atomize_keys(value) when is_list(value), do: Enum.map(value, &safe_atomize_keys/1)
def safe_atomize_keys(value), do: value
end end

View File

@@ -69,7 +69,7 @@ defmodule BDS.ReleasePackaging do
defp normalize_platform(:darwin), do: :macos defp normalize_platform(:darwin), do: :macos
defp normalize_platform(platform) when is_binary(platform), defp normalize_platform(platform) when is_binary(platform),
do: platform |> String.downcase() |> String.to_atom() do: platform |> String.downcase() |> String.to_existing_atom()
defp archive_extension(:windows), do: ".zip" defp archive_extension(:windows), do: ".zip"
defp archive_extension(_platform), do: ".tar.gz" defp archive_extension(_platform), do: ".tar.gz"

View File

@@ -121,7 +121,7 @@ defmodule BDS.UI.MenuBar do
defp sidebar_view_command(command_id) do defp sidebar_view_command(command_id) do
with "view_" <> suffix <- Atom.to_string(command_id), with "view_" <> suffix <- Atom.to_string(command_id),
view_id = String.to_atom(suffix), view_id = String.to_existing_atom(suffix),
%{} <- Registry.sidebar_view(view_id) do %{} <- Registry.sidebar_view(view_id) do
{:ok, view_id} {:ok, view_id}
else else
@@ -131,7 +131,7 @@ defmodule BDS.UI.MenuBar do
defp singleton_editor_command(command_id) do defp singleton_editor_command(command_id) do
with "open_" <> suffix <- Atom.to_string(command_id), with "open_" <> suffix <- Atom.to_string(command_id),
route_id = String.to_atom(suffix), route_id = String.to_existing_atom(suffix),
%{singleton: true} <- Registry.editor_route(route_id) do %{singleton: true} <- Registry.editor_route(route_id) do
{:ok, route_id} {:ok, route_id}
else else

View File

@@ -267,7 +267,7 @@ defmodule BDS.UI.Workbench do
defp token_usage(_state, _usage), do: nil defp token_usage(_state, _usage), do: nil
defp normalize_type(type) when is_atom(type), do: type defp normalize_type(type) when is_atom(type), do: type
defp normalize_type(type) when is_binary(type), do: String.to_atom(type) defp normalize_type(type) when is_binary(type), do: String.to_existing_atom(type)
defp tab_ref(tab), do: {tab.type, tab.id} defp tab_ref(tab), do: {tab.type, tab.id}

View File

@@ -285,6 +285,28 @@ defmodule BDS.AITest do
:ok :ok
end end
test "model_capabilities does not create atoms from unknown capability keys" do
before = :erlang.system_info(:atom_count)
malicious_caps =
Jason.encode!(%{
"supports_attachment" => true,
"supports_tool_calls" => false,
"disables_reasoning" => false,
"csm001_fictive_#{:erlang.unique_integer()}" => true,
"another_unknown_#{:erlang.unique_integer()}" => 42
})
:ok = BDS.AI.SettingsStore.put_setting("ai.model_capabilities.csm-test-model", malicious_caps)
result = BDS.AI.Catalog.model_capabilities("csm-test-model")
assert result.supports_attachment == true
assert result.supports_tool_calls == false
after_count = :erlang.system_info(:atom_count)
assert after_count == before
end
test "put_endpoint, get_endpoint, and delete_endpoint manage encrypted endpoint settings" do test "put_endpoint, get_endpoint, and delete_endpoint manage encrypted endpoint settings" do
assert {:ok, endpoint} = assert {:ok, endpoint} =
BDS.AI.put_endpoint( BDS.AI.put_endpoint(

View File

@@ -55,20 +55,28 @@ defmodule BDS.BoundedAtomsTest do
assert BoundedAtoms.shell_command("unknown") == nil assert BoundedAtoms.shell_command("unknown") == nil
end end
test "codebase does not use String.to_existing_atom rescues" do test "codebase does not use String.to_atom on dynamic data" do
lib_dir = Path.expand("../../lib", __DIR__) lib_dir = Path.expand("../../lib", __DIR__)
allowed_files = [
"bounded_atoms.ex",
"map_utils.ex"
]
offenders = offenders =
lib_dir lib_dir
|> Path.join("**/*.ex") |> Path.join("**/*.ex")
|> Path.wildcard() |> Path.wildcard()
|> Enum.reject(&String.ends_with?(&1, "bounded_atoms.ex")) |> Enum.reject(fn path ->
|> Enum.filter(fn path -> Enum.any?(allowed_files, &String.ends_with?(path, &1))
path
|> File.read!()
|> String.contains?("String.to_existing_atom")
end) end)
|> Enum.filter(fn path ->
content = File.read!(path)
String.contains?(content, "String.to_atom(")
end)
|> Enum.map(&Path.relative_to(&1, lib_dir))
assert offenders == [] assert offenders == [],
"Files still using String.to_atom (use String.to_existing_atom or BDS.MapUtils.safe_atomize_key): #{Enum.join(offenders, ", ")}"
end end
end end

View File

@@ -18,6 +18,34 @@ defmodule BDS.ImportDefinitionsTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "decode_analysis_result does not create atoms from unknown keys" do
unique_suffix = :erlang.unique_integer()
unknown_key_1 = "csm001_fictive_#{unique_suffix}"
unknown_key_2 = "csm001_nested_#{unique_suffix}"
malicious_json =
Jason.encode!(%{
unknown_key_1 => "val",
"site_info" => %{unknown_key_2 => "nested_val"}
})
result = ImportDefinitions.decode_analysis_result(malicious_json)
assert is_map(result)
assert Map.get(result, unknown_key_1) == "val" or Map.get(result, "csm001_fictive_#{unique_suffix}") == "val"
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key_1) end
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key_2) end
end
test "decode_analysis_result converts known keys to atoms" do
json = Jason.encode!(%{"site_info" => %{"title" => "My Blog"}})
result = ImportDefinitions.decode_analysis_result(json)
assert is_map(result)
assert Map.get(result, :site_info) != nil or Map.get(result, "site_info") != nil
end
test "get, update, and delete round-trip import definition editor state", %{ test "get, update, and delete round-trip import definition editor state", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir

View File

@@ -22,6 +22,39 @@ defmodule BDS.ImportExecutionTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "execute_import does not create atoms from malicious report keys", %{
project: project
} do
unique_suffix = :erlang.unique_integer()
unknown_key_1 = "csm001_malicious_#{unique_suffix}"
unknown_key_2 = "csm001_nested_#{unique_suffix}"
malicious_report = %{
"items" => %{
"categories" => [],
"tags" => [],
"posts" => [],
"pages" => [],
"media" => []
},
"details" => %{
"posts" => [],
"pages" => [],
"media" => []
},
unknown_key_1 => "attack",
"extra" => %{unknown_key_2 => "nested_attack"}
}
assert {:ok, _result} =
ImportExecution.execute_import(project.id, malicious_report,
default_author: "Test Author"
)
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key_1) end
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key_2) end
end
test "execute_import creates tags, posts, pages, and media from the analysis report", %{ test "execute_import creates tags, posts, pages, and media from the analysis report", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir

View File

@@ -35,6 +35,70 @@ defmodule BDS.MapUtilsTest do
end end
end end
describe "safe_atomize_key/1" do
test "converts known string keys to existing atoms" do
_ = :title
_ = :status
assert MapUtils.safe_atomize_key("title") == :title
assert MapUtils.safe_atomize_key("status") == :status
end
test "leaves unknown string keys as strings without creating new atoms" do
unique_keys = for i <- 1..100, do: "csm001_fictive_#{i}_#{:erlang.unique_integer()}"
Enum.each(unique_keys, fn key ->
result = MapUtils.safe_atomize_key(key)
assert is_binary(result)
assert result == key
assert_raise ArgumentError, fn -> String.to_existing_atom(key) end
end)
end
test "passes atoms through unchanged" do
assert MapUtils.safe_atomize_key(:title) == :title
end
test "safe_atomize_keys recursively converts map keys safely" do
input = %{
"title" => "Hello",
"status" => "draft",
"nested" => %{"title" => "Inner", "completely_unknown_key" => "val"},
"items" => [%{"title" => "One"}, %{"title" => "Two"}]
}
_ = :title
_ = :status
_ = :nested
_ = :items
result = MapUtils.safe_atomize_keys(input)
assert result.title == "Hello"
assert result.status == "draft"
assert result.nested.title == "Inner"
assert Map.get(result.nested, "completely_unknown_key") == "val"
assert length(result.items) == 2
end
test "safe_atomize_keys does not create atoms for malicious payloads" do
unique_suffix = :erlang.unique_integer()
malicious = for i <- 1..500, into: %{} do
{"csm001_malicious_#{i}_#{unique_suffix}", "val"}
end
result = MapUtils.safe_atomize_keys(malicious)
assert map_size(result) == 500
Enum.each(1..500, fn i ->
key = "csm001_malicious_#{i}_#{unique_suffix}"
assert Map.get(result, key) == "val"
assert_raise ArgumentError, fn -> String.to_existing_atom(key) end
end)
end
end
describe "atom/string key duality" do describe "atom/string key duality" do
test "shared attr helper is used for same-name atom and string reads" do test "shared attr helper is used for same-name atom and string reads" do
root = File.cwd!() root = File.cwd!()