Skip to content

Instantly share code, notes, and snippets.

@ftes
Last active June 30, 2025 14:35
Show Gist options
  • Save ftes/895d44a929502583803cdbdfd07f0da2 to your computer and use it in GitHub Desktop.
Save ftes/895d44a929502583803cdbdfd07f0da2 to your computer and use it in GitHub Desktop.
Elixir NimbleCSV prevent CSV injection via credo rule
defmodule MyApp.CSV do
@moduledoc false
NimbleCSV.define(MyyApp.CSV.RFC4180,
# defaults from NimbleCSV.Spreadsheet
separator: ",",
escape: "\"",
line_separator: "\r\n",
escape_formula: %{["@", "+", "-", "=", "\t", "\r"] => "'"}
)
NimbleCSV.define(MyApp.CSV.Spreadsheet,
# defaults from NimbleCSV.Spreadsheet
separator: "\t",
escape: "\"",
encoding: {:utf16, :little},
trim_bom: true,
dump_bom: true,
# custom extension
escape_formula: %{["@", "+", "-", "=", "\t", "\r"] => "'"}
)
end
defmodule MyApp.Credo.NoNimbleCSV do
@moduledoc false
use Credo.Check,
base_priority: :high,
category: :warning,
explanations: [
check: """
To prevent [CSV injection](https://owasp.org/www-community/attacks/CSV_Injection)
use `Extensions.NimbleCSV` modules instead of `NimbleCSV`.
"""
]
@forbidden ["NimbleCSV.Spreadsheet", "NimbleCSV.RFC4180"]
@doc false
def run(%SourceFile{} = source_file, params) do
issue_meta = IssueMeta.for(source_file, params)
Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
end
# Check for any reference to the forbidden module
defp traverse({:__aliases__, meta, module_parts} = ast, issues, issue_meta) do
module_name = module_parts |> Enum.filter(&is_atom/1) |> Enum.map_join(".", &to_string/1)
if module_name in @forbidden do
issue = issue_for(issue_meta, meta[:line], module_name)
{ast, [issue | issues]}
else
{ast, issues}
end
end
# Default case - continue traversing
defp traverse(ast, issues, _issue_meta) do
{ast, issues}
end
defp issue_for(issue_meta, line_no, module_name) do
format_issue(
issue_meta,
message: "Usage of #{module_name} is not allowed. Use MyApp.CSV.#{module_name} instead.",
line_no: line_no,
trigger: module_name
)
end
end
defmodule MyApp.Credo.NoNimbleCSVTest do
use Credo.Test.Case, async: true
alias MyApp.Credo.NoNimbleCSV
test "does NOT report expected code" do
"""
MyApp.NimbleCSV.RFC4180.dump_to_iodata([])
"""
|> to_source_file()
|> run_check(NoNimbleCSV)
|> refute_issues()
end
test "reports fully qualified call of RFC4180.dump_to_iodata" do
"""
NimbleCSV.RFC4180.dump_to_iodata([])
"""
|> to_source_file()
|> run_check(NoNimbleCSV)
|> assert_issue()
end
test "reports fully qualified call of Spreadsheet.dump_to_iodata" do
"""
NimbleCSV.Spreadsheet.dump_to_iodata([])
"""
|> to_source_file()
|> run_check(NoNimbleCSV)
|> assert_issue()
end
test "reports alias of Spreadsheet" do
"""
alias NimbleCSV.Spreadsheet
"""
|> to_source_file()
|> run_check(NoNimbleCSV)
|> assert_issue()
end
test "does not fail on __MODULE__ alias" do
"""
alias __MODULE__.Something
"""
|> to_source_file()
|> run_check(NoNimbleCSV)
|> refute_issues()
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment