IMO, the appropriate response to something like this is to drop the message in its entirety, taking the classic "let it crash" approach for Erlang/Elixir. No need to pass the known to be malicious message along to potentially less fault tolerant parts of your system.
One classic exploit I've seen being possible multiple times is when you allow a subset of html and remove other tags. A naive approach will for instance remove <script>. But what happen if I then send in <scr<script>ipt>? If it only does one pass, I fooled it. Instead, just assume the input is done in bad faith when it's encountered and crash, instead of kneading it to something acceptable.
If you use a syntactical parser, you can guarantee your output is safe, all the time. If you're using non-syntactical techniques, then you have to go and find all these edge cases that you should crash on in the first place.
ElixirConf 2019 - High Performance String Processing Scripts in Elixir - Johanna Larsson
I wonder: could the algorithm implemented in unicode_string be improved? Or were the algorithms implemented as Rust NIFs also prone to this non-linear complexity?
They are repeatedly calling Regex.split on the trailing part of the string as they move across the string. Regex.split finds all the matches in the string so runs in linear time so you get a quadratic algorithm. The elixir/erlang regex implementation does not check if the string is valid utf8 before running the regex. They were getting the error because the whole string was being evaluated by the regex engine. It probably won't be as fast as the rust solution but it won't be horribly slow on large strings.
NSFL regex that implements word breaking: https://github.com/BurntSushi/bstr/blob/86947727666d7b21c97e...
I agree, that in a scenario where you control both ends of transmission, a boring binary chunk would be easier and simpler.
To conclude that the performance data shows exponential complexity is erroneous: the data sent to Wolfram Alpha doubles with each data-point because the input size is doubling, but the x-axis info isn't being supplied to Alpha. Eyeballing the data, the last 2 data points are 5.85 and 11.8, which is very close to double (11.7 vs 11.8), suggesting linear complexity.
Thanks for pointing out the mistake in the data sent to Wolfram Alpha! Still, if you look at the results table, you can see that the factor of increase is ~2x at the beginning, but is ~3x at the end.
(This is why I wanted a plot instead of a table in the blog post :p, because then the curve speaks for itself)
Interesting topic, thanks for writing the blog post!
But 1us/byte checking for word boundaries - that's on the order of 10k instructions per byte! I knew that unicode was more complex than ASCII but yikes!
For example:
iex(86)> v = "£AXAX" <> String.duplicate("A", 10_000) <> "\xFF"; {:ok, r} = :re.compile("[£-¨]"); :timer.tc(fn() -> :re.run(v, r) end)
{65, {:match, [{0, 1}]}}
Also, even though Regex.split() returns two strings that are the size of the input the function does not necessarily have to linear because erlang has fast substring slicing. Example: iex(47)> x = String.duplicate("A", 100_000); y = :binary.part(x, {0, 5000}); :binary.referenced_byte_size(y)
100000
Though, its probably not safe to rely on this because :binary.part() will not always use a fast slice if the slice is not big enough. I know some erlang parsing code explicitly keeps track of indices instead of using <<"foo", rest::binary()>> idiom presumably because its faster.Here are some timings from Regex.split() that show that it is linear to the size of the input. You can see there are some very big constant costs that hide that Regex.split() is linear when not using unicode ranges.
iex(25)> v = "AXAX" <> String.duplicate("A", 1); r = ~r/[£-¨]/; :timer.tc(fn() -> Regex.split(r, v, parts: 2) end)
{31, ["AXAXA"]}
iex(26)> v = "AXAX" <> String.duplicate("A", 10_000); r = ~r/[£-¨]/; :timer.tc(fn() -> Regex.split(r, v, parts: 2) end)
{232, ...}
iex(37)> v = "AXAX" <> String.duplicate("A", 1); r = ~r/X/; :timer.tc(fn() -> Regex.split(r, v, parts: 2) end)
{39, ["A", "AXA"]}
iex(29)> v = "AXAX" <> String.duplicate("A", 10_000); r = ~r/X/; :timer.tc(fn() -> Regex.split(r, v, parts: 2) end)
{38, ...}
iex(38)> v = "AXAX" <> String.duplicate("A", 100_000); r = ~r/X/; :timer.tc(fn() -> Regex.split(r, v, parts: 2) end)
{274, ..}