defmodule StripJs do
@moduledoc ~S"""
StripJs is an Elixir module for stripping executable JavaScript from
blocks of HTML. It removes or deactivates `` and `` tags
are removed entirely.
* `` is converted to
``.
* Event handler attributes such as `onclick="..."` are converted to
e.g., `data-onclick="..."`.
StripJs output is always HTML-escaped to prevent HTML entity attacks
(like `<script>`).
## Installation
Add `strip_js` to your application's dependencies in `mix.exs`:
def deps do
[{:strip_js, "~> 0.7.0"}]
end
## Usage
`strip_js/1` returns a copy of its input, with all JS removed.
iex> html = ""
iex> StripJs.strip_js(html)
""
StripJs relies on the [Floki](https://github.com/philss/floki)
HTML parser library. StripJs provides a `strip_js_from_tree/1`
function to strip JS from Floki HTML parse trees.
## Similar packages
[phoenix_html_sanitizer](https://github.com/elixirstatus/phoenix_html_sanitizer),
based on [html_sanitize_ex](https://github.com/rrrene/html_sanitize_ex),
provides similar functionality with its `:full_html` mode.
However, in addition to using the Phoenix.HTML.Safe protocol (returning
tuples like `{:safe, string}`), phoenix_html_sanitizer maintains the
contents of `script` tags, effectively pasting deactivated JS into the DOM.
StripJs improves on this behavior by removing the contents of `script` tags
entirely.
"""
@doc ~S"""
Returns a copy of the given HTML string with all JS removed.
All non-tag text and tag attribute values will be HTML-escaped.
Even if the input HTML contained no JS, the output is not guaranteed
to match byte-for-byte, but it will be equivalent HTML.
Examples:
iex> StripJs.strip_js("")
""
iex> StripJs.strip_js("")
""
iex> StripJs.strip_js("<script> console.log('oh heck'); </script>")
"<script> console.log('oh heck'); </script>" ## HTML entity attack didn't work
"""
@spec strip_js(String.t) :: String.t
def strip_js(html) when is_binary(html) do
html
|> Floki.parse
|> strip_js_from_tree
|> to_html
end
@spec to_html(Floki.html_tree) :: String.t
defp to_html(html) when is_binary(html), do: html
defp to_html(tree), do: tree |> Floki.raw_html
@doc ~S"""
Returns a copy of the given Floki HTML tree with all JS removed.
All tag bodies and attribute values will be HTML-escaped.
"""
@spec strip_js_from_tree(Floki.html_tree) :: Floki.html_tree
def strip_js_from_tree(trees) when is_list(trees) do
Enum.map(trees, &(strip_js_from_tree(&1)))
end
def strip_js_from_tree({tag, attrs, children}) do
case String.downcase(tag) do
"script" ->
"" # remove scripts entirely
_ ->
stripped_children = Enum.map(children, &(strip_js_from_tree(&1)))
{tag, clean_attrs(attrs), stripped_children}
end
end
def strip_js_from_tree(string) when is_binary(string) do
string |> html_escape
end
## Removes attributes that carry JS; namely `href="javascript:..."` and
## `onevent="..."` handlers (onclick, onchange, etc).
@spec clean_attrs([{String.t, String.t}]) :: [{String.t, String.t}]
defp clean_attrs(attrs) do
rev_attrs = Enum.reduce attrs, [], fn ({attr, value}, acc) ->
case String.downcase(attr) do
## ... becomes
## ...
"href" ->
case String.downcase(value) do
"javascript:" <> _rest ->
rest = value |> String.slice(11..-1) |> html_escape
[{attr, "#"}, {"data-href-javascript", rest} | acc]
_ ->
[{attr, html_escape(value)} | acc]
end
## ... becomes
## ...
"on" <> event ->
[{"data-on#{event}", html_escape(value)} | acc]
_ ->
[{attr, html_escape(value)} | acc]
end
end
rev_attrs |> Enum.reverse
end
## Performs good-enough HTML escaping to prevent HTML entity attacks.
@spec html_escape(String.t) :: String.t
defp html_escape(html) do
html
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("\"", """)
end
end