diff --git a/lib/util/safe_regex.ex b/lib/util/safe_regex.ex new file mode 100644 index 0000000..0923e89 --- /dev/null +++ b/lib/util/safe_regex.ex @@ -0,0 +1,59 @@ +defmodule Util.SafeRegex do + @moduledoc """ + Bounded regex matching for parameter input format validation. Caps + pattern and value sizes; PCRE's `match_limit` short-circuits runaway + matches. + """ + + @max_pattern_length 512 + @max_value_length 4_096 + + @type match_error :: :pattern_too_long | :value_too_long | :invalid_pattern + + @spec max_pattern_length() :: pos_integer() + def max_pattern_length, do: @max_pattern_length + + @spec max_value_length() :: pos_integer() + def max_value_length, do: @max_value_length + + @spec validate_pattern(String.t() | nil) :: :ok | {:error, match_error()} + def validate_pattern(nil), do: {:error, :invalid_pattern} + def validate_pattern(""), do: {:error, :invalid_pattern} + + def validate_pattern(pattern) when is_binary(pattern) do + if byte_size(pattern) > @max_pattern_length do + {:error, :pattern_too_long} + else + case :re.compile(pattern) do + {:ok, _compiled} -> :ok + {:error, _reason} -> {:error, :invalid_pattern} + end + end + end + + @spec match(String.t() | nil, String.t() | nil) :: + {:ok, boolean()} | {:error, match_error()} + def match(nil, _value), do: {:error, :invalid_pattern} + def match(_pattern, nil), do: {:ok, false} + + def match(pattern, value) when is_binary(pattern) and is_binary(value) do + cond do + byte_size(pattern) > @max_pattern_length -> {:error, :pattern_too_long} + byte_size(value) > @max_value_length -> {:error, :value_too_long} + true -> run(pattern, value) + end + end + + defp run(pattern, value) do + case :re.compile(pattern) do + {:ok, compiled} -> + case :re.run(value, compiled) do + {:match, _captures} -> {:ok, true} + :nomatch -> {:ok, false} + end + + {:error, _reason} -> + {:error, :invalid_pattern} + end + end +end diff --git a/mix.exs b/mix.exs index 5dc5f8c..015d90c 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule Util.Mixfile do def project do [app: :util, - version: "0.0.1", + version: "0.1.0", elixir: "~> 1.4", build_embedded: Mix.env == :prod, start_permanent: Mix.env == :prod, diff --git a/test/safe_regex_test.exs b/test/safe_regex_test.exs new file mode 100644 index 0000000..1dae014 --- /dev/null +++ b/test/safe_regex_test.exs @@ -0,0 +1,67 @@ +defmodule Util.SafeRegexTest do + use ExUnit.Case, async: true + + alias Util.SafeRegex + + describe "validate_pattern/1" do + test "accepts a valid pattern" do + assert :ok = SafeRegex.validate_pattern("^[0-9]+$") + end + + test "rejects nil" do + assert {:error, :invalid_pattern} = SafeRegex.validate_pattern(nil) + end + + test "rejects empty pattern" do + assert {:error, :invalid_pattern} = SafeRegex.validate_pattern("") + end + + test "rejects malformed pattern" do + assert {:error, :invalid_pattern} = SafeRegex.validate_pattern("[") + end + + test "rejects pattern over the length cap" do + pattern = String.duplicate("a", SafeRegex.max_pattern_length() + 1) + assert {:error, :pattern_too_long} = SafeRegex.validate_pattern(pattern) + end + end + + describe "match/2" do + test "returns {:ok, true} on match" do + assert {:ok, true} = SafeRegex.match("^[0-9]+$", "123") + end + + test "returns {:ok, false} on no match" do + assert {:ok, false} = SafeRegex.match("^[0-9]+$", "abc") + end + + test "rejects pattern over the length cap" do + pattern = String.duplicate("a", SafeRegex.max_pattern_length() + 1) + assert {:error, :pattern_too_long} = SafeRegex.match(pattern, "anything") + end + + test "rejects value over the length cap" do + value = String.duplicate("a", SafeRegex.max_value_length() + 1) + assert {:error, :value_too_long} = SafeRegex.match("^a+$", value) + end + + test "rejects malformed pattern" do + assert {:error, :invalid_pattern} = SafeRegex.match("[", "anything") + end + + test "bounded execution of an adversarial pattern terminates" do + pattern = "^([a-zA-Z]+)*$" + value = String.duplicate("a", 50) <> "1" + + assert {:ok, false} = SafeRegex.match(pattern, value) + end + + test "treats nil value as no match" do + assert {:ok, false} = SafeRegex.match("^a+$", nil) + end + + test "treats nil pattern as invalid" do + assert {:error, :invalid_pattern} = SafeRegex.match(nil, "anything") + end + end +end