From 9d5507726de36dacb60414353e139dbee8fdca77 Mon Sep 17 00:00:00 2001 From: Jonathan Gao Date: Fri, 15 Apr 2022 18:29:00 +0800 Subject: [PATCH] fix: Fix link_attributes. --- lib/phoenix_webcomponent.ex | 100 +++++++++++++++++++ lib/phoenix_webcomponent/link.ex | 6 +- test/phoenix_webcomponent/form_test.exs | 3 +- test/phoenix_webcomponent/link_test.exs | 84 ++++++++-------- test/phoenix_webcomponent_test.exs | 125 ------------------------ 5 files changed, 147 insertions(+), 171 deletions(-) diff --git a/lib/phoenix_webcomponent.ex b/lib/phoenix_webcomponent.ex index 2f482450..053509f2 100644 --- a/lib/phoenix_webcomponent.ex +++ b/lib/phoenix_webcomponent.ex @@ -69,4 +69,104 @@ defmodule Phoenix.WebComponent do end end + @doc """ + Returns a list of attributes that make an element behave like a link. + For example, to make a button work like a link: + + However, this function is more often used to create buttons that + must invoke an action on the server, such as deleting an entity, + using the relevant HTTP protocol: + + The `to` argument may be a string, a URI, or a tuple `{scheme, value}`. + See the examples below. + Note: using this function requires loading the JavaScript library + at `priv/static/phoenix_html.js`. See the `Phoenix.HTML` module + documentation for more information. + ## Options + * `:method` - the HTTP method for the link. Defaults to `:get`. + * `:csrf_token` - a custom token to use when method is not `:get`. + This is used to ensure the request was sent by the user who + rendered the page. By default, CSRF tokens are generated through + `Plug.CSRFProtection`. You can set this option to `false`, to + disable token generation, or set it to your own token. + When the `:method` is set to `:get` and the `:to` URL contains query + parameters the generated form element will strip the parameters in + accordance with the [W3C](https://www.w3.org/TR/html401/interact/forms.html#h-17.13.3.4) + form specification. + ## Data attributes + The following data attributes can also be manually set in the element: + * `data-confirm` - shows a confirmation prompt before generating and + submitting the form. + ## Examples + iex> link_attributes("/world") + [data: [method: :get, to: "/world"]] + iex> link_attributes(URI.parse("https://elixir-lang.org")) + [data: [method: :get, to: "https://elixir-lang.org"]] + iex> link_attributes("/product/1", method: :delete) + [data: [csrf: Plug.CSRFProtection.get_csrf_token(), method: :delete, to: "/product/1"]] + If the URL is absolute, only certain schemas are allowed to + avoid JavaScript injection. For example, the following will fail: + iex> link_attributes("javascript:alert('hacked!')") + ** (ArgumentError) unsupported scheme given as link. In case you want to link to an + unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest} + You can however explicitly render those unsafe schemes by using a tuple: + iex> link_attributes({:javascript, "alert('my alert!')"}) + [data: [method: :get, to: ["javascript", 58, "alert('my alert!')"]]] + """ + def link_attributes(to, opts \\ []) do + to = valid_destination!(to) + method = Keyword.get(opts, :method, :get) + data = [method: method, to: to] + + data = + if method == :get do + data + else + case Keyword.get(opts, :csrf_token, true) do + true -> [csrf: Phoenix.HTML.Tag.csrf_token_value(to)] ++ data + false -> data + csrf when is_binary(csrf) -> [csrf: csrf] ++ data + end + end + + [data: data] + end + + defp valid_destination!(%URI{} = uri) do + valid_destination!(URI.to_string(uri)) + end + + defp valid_destination!({:safe, to}) do + {:safe, valid_string_destination!(IO.iodata_to_binary(to))} + end + + defp valid_destination!({other, to}) when is_atom(other) do + [Atom.to_string(other), ?:, to] + end + + defp valid_destination!(to) do + valid_string_destination!(IO.iodata_to_binary(to)) + end + + @valid_uri_schemes ~w(http: https: ftp: ftps: mailto: news: irc: gopher:) ++ + ~w(nntp: feed: telnet: mms: rtsp: svn: tel: fax: xmpp:) + + for scheme <- @valid_uri_schemes do + defp valid_string_destination!(unquote(scheme) <> _ = string), do: string + end + + defp valid_string_destination!(to) do + if not match?("/" <> _, to) and String.contains?(to, ":") do + raise ArgumentError, """ + unsupported scheme given as link. In case you want to link to an + unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest}\ + """ + else + to + end + end end diff --git a/lib/phoenix_webcomponent/link.ex b/lib/phoenix_webcomponent/link.ex index b19f1d0a..5dc1a9ba 100644 --- a/lib/phoenix_webcomponent/link.ex +++ b/lib/phoenix_webcomponent/link.ex @@ -78,14 +78,14 @@ defmodule Phoenix.WebComponent.Link do if method == :get do # Call link attributes to validate `to` - [data: data] = Phoenix.HTML.link_attributes(to, []) + [data: data] = Phoenix.WebComponent.link_attributes(to, []) content_tag(:a, text, [href: data[:to]] ++ Keyword.delete(opts, :csrf_token)) else {csrf_token, opts} = Keyword.pop(opts, :csrf_token, true) opts = Keyword.put_new(opts, :rel, "nofollow") [data: data] = - Phoenix.HTML.link_attributes(to, method: method, csrf_token: csrf_token) + Phoenix.WebComponent.link_attributes(to, method: method, csrf_token: csrf_token) content_tag(:a, text, [data: data, href: data[:to]] ++ opts) end @@ -139,7 +139,7 @@ defmodule Phoenix.WebComponent.Link do |> Keyword.put_new(:method, :post) |> Keyword.split([:method, :csrf_token]) - link_attributes = Phoenix.HTML.link_attributes(to, link_opts) + link_attributes = Phoenix.WebComponent.link_attributes(to, link_opts) content_tag(:"mwc-button", text, link_attributes ++ opts) end diff --git a/test/phoenix_webcomponent/form_test.exs b/test/phoenix_webcomponent/form_test.exs index c8ec1a11..568e11a5 100644 --- a/test/phoenix_webcomponent/form_test.exs +++ b/test/phoenix_webcomponent/form_test.exs @@ -1,7 +1,8 @@ defmodule Phoenix.WebComponent.FormTest do use ExUnit.Case, async: true - import Phoenix.WebComponent + import Phoenix.HTML + import Phoenix.HTML.Form import Phoenix.WebComponent.Form doctest Phoenix.WebComponent.Form diff --git a/test/phoenix_webcomponent/link_test.exs b/test/phoenix_webcomponent/link_test.exs index 2b7db8e2..02061541 100644 --- a/test/phoenix_webcomponent/link_test.exs +++ b/test/phoenix_webcomponent/link_test.exs @@ -1,149 +1,149 @@ defmodule Phoenix.WebComponent.LinkTest do use ExUnit.Case, async: true - import Phoenix.WebComponent + import Phoenix.HTML import Phoenix.WebComponent.Link test "link with post" do csrf_token = Plug.CSRFProtection.get_csrf_token() - assert safe_to_string(link("hello", to: "/world", method: :post)) == + assert safe_to_string(wc_link("hello", to: "/world", method: :post)) == ~s[hello] end test "link with %URI{}" do url = "https://elixir-lang.org/" - assert safe_to_string(link("elixir", to: url)) == - safe_to_string(link("elixir", to: URI.parse(url))) + assert safe_to_string(wc_link("elixir", to: url)) == + safe_to_string(wc_link("elixir", to: URI.parse(url))) path = "/elixir" - assert safe_to_string(link("elixir", to: path)) == - safe_to_string(link("elixir", to: URI.parse(path))) + assert safe_to_string(wc_link("elixir", to: path)) == + safe_to_string(wc_link("elixir", to: URI.parse(path))) end test "link with put/delete" do csrf_token = Plug.CSRFProtection.get_csrf_token() - assert safe_to_string(link("hello", to: "/world", method: :put)) == + assert safe_to_string(wc_link("hello", to: "/world", method: :put)) == ~s[hello] end test "link with put/delete without csrf_token" do - assert safe_to_string(link("hello", to: "/world", method: :put, csrf_token: false)) == + assert safe_to_string(wc_link("hello", to: "/world", method: :put, csrf_token: false)) == ~s[hello] end test "link with :do contents" do assert ~s[

world

] == safe_to_string( - link to: "/hello" do + wc_link to: "/hello" do Phoenix.WebComponent.Tag.content_tag(:p, "world") end ) assert safe_to_string( - link(to: "/hello") do + wc_link(to: "/hello") do "world" end ) == ~s[world] end test "link with scheme" do - assert safe_to_string(link("foo", to: "/javascript:alert(<1>)")) == + assert safe_to_string(wc_link("foo", to: "/javascript:alert(<1>)")) == ~s[foo] - assert safe_to_string(link("foo", to: {:safe, "/javascript:alert(<1>)"})) == + assert safe_to_string(wc_link("foo", to: {:safe, "/javascript:alert(<1>)"})) == ~s[foo] - assert safe_to_string(link("foo", to: {:javascript, "alert(<1>)"})) == + assert safe_to_string(wc_link("foo", to: {:javascript, "alert(<1>)"})) == ~s[foo] - assert safe_to_string(link("foo", to: {:javascript, 'alert(<1>)'})) == + assert safe_to_string(wc_link("foo", to: {:javascript, 'alert(<1>)'})) == ~s[foo] - assert safe_to_string(link("foo", to: {:javascript, {:safe, "alert(<1>)"}})) == + assert safe_to_string(wc_link("foo", to: {:javascript, {:safe, "alert(<1>)"}})) == ~s[foo] - assert safe_to_string(link("foo", to: {:javascript, {:safe, 'alert(<1>)'}})) == + assert safe_to_string(wc_link("foo", to: {:javascript, {:safe, 'alert(<1>)'}})) == ~s[foo] end test "link with invalid args" do - msg = "expected non-nil value for :to in link/2" + msg = "expected non-nil value for :to in wc_link/2" assert_raise ArgumentError, msg, fn -> - link("foo", bar: "baz") + wc_link("foo", bar: "baz") end msg = "link/2 requires a keyword list as second argument" assert_raise ArgumentError, msg, fn -> - link("foo", "/login") + wc_link("foo", "/login") end assert_raise ArgumentError, ~r"unsupported scheme given as link", fn -> - link("foo", to: "javascript:alert(1)") + wc_link("foo", to: "javascript:alert(1)") end assert_raise ArgumentError, ~r"unsupported scheme given as link", fn -> - link("foo", to: {:safe, "javascript:alert(1)"}) + wc_link("foo", to: {:safe, "javascript:alert(1)"}) end assert_raise ArgumentError, ~r"unsupported scheme given as link", fn -> - link("foo", to: {:safe, 'javascript:alert(1)'}) + wc_link("foo", to: {:safe, 'javascript:alert(1)'}) end end - test "button with post (default)" do + test "wc_button with post (default)" do csrf_token = Plug.CSRFProtection.get_csrf_token() - assert safe_to_string(button("hello", to: "/world")) == - ~s[] + assert safe_to_string(wc_button("hello", to: "/world")) == + ~s[hello] end - test "button with %URI{}" do + test "wc_button with %URI{}" do url = "https://elixir-lang.org/" - assert safe_to_string(button("elixir", to: url, csrf_token: false)) == - safe_to_string(button("elixir", to: URI.parse(url), csrf_token: false)) + assert safe_to_string(wc_button("elixir", to: url, csrf_token: false)) == + safe_to_string(wc_button("elixir", to: URI.parse(url), csrf_token: false)) end - test "button with post without csrf_token" do - assert safe_to_string(button("hello", to: "/world", csrf_token: false)) == - ~s[] + test "wc_button with post without csrf_token" do + assert safe_to_string(wc_button("hello", to: "/world", csrf_token: false)) == + ~s[hello] end - test "button with get does not generate CSRF" do - assert safe_to_string(button("hello", to: "/world", method: :get)) == - ~s[] + test "wc_button with get does not generate CSRF" do + assert safe_to_string(wc_button("hello", to: "/world", method: :get)) == + ~s[hello] end - test "button with do" do + test "wc_button with do" do csrf_token = Plug.CSRFProtection.get_csrf_token() output = safe_to_string( - button to: "/world", class: "small" do + wc_button to: "/world", class: "small" do raw("Hi") end ) assert output == - ~s[] + ~s[Hi] end - test "button with class overrides default" do + test "wc_button with class overrides default" do csrf_token = Plug.CSRFProtection.get_csrf_token() - assert safe_to_string(button("hello", to: "/world", class: "btn rounded", id: "btn")) == - ~s[] + assert safe_to_string(wc_button("hello", to: "/world", class: "btn rounded", id: "btn")) == + ~s[hello] end - test "button with invalid args" do + test "wc_button with invalid args" do assert_raise ArgumentError, ~r/unsupported scheme given as link/, fn -> - button("foo", to: "javascript:alert(1)", method: :get) + wc_button("foo", to: "javascript:alert(1)", method: :get) end end end diff --git a/test/phoenix_webcomponent_test.exs b/test/phoenix_webcomponent_test.exs index e236b549..c359dc5a 100644 --- a/test/phoenix_webcomponent_test.exs +++ b/test/phoenix_webcomponent_test.exs @@ -4,129 +4,4 @@ defmodule Phoenix.WebComponentTest do use Phoenix.WebComponent doctest Phoenix.WebComponent - test "~E sigil" do - assert ~E""" - <%= "foo" %> - """ == {:safe, ["foo", "\n"]} - end - - test "javascript_escape/1" do - assert javascript_escape("") == "" - assert javascript_escape("\\Double backslash") == "\\\\Double backslash" - assert javascript_escape("\"Double quote\"") == "\\\"Double quote\\\"" - assert javascript_escape("'Single quote'") == "\\'Single quote\\'" - assert javascript_escape("`Backtick`") == "\\`Backtick\\`" - assert javascript_escape("New line\r") == "New line\\n" - assert javascript_escape("New line\n") == "New line\\n" - assert javascript_escape("New line\r\n") == "New line\\n" - assert javascript_escape("") == "<\\/close>" - assert javascript_escape("Line separator\u2028") == "Line separator\\u2028" - assert javascript_escape("Paragraph separator\u2029") == "Paragraph separator\\u2029" - assert javascript_escape("Null character\u0000") == "Null character\\u0000" - assert javascript_escape({:safe, "'Single quote'"}) == {:safe, "\\'Single quote\\'"} - assert javascript_escape({:safe, ["'Single quote'"]}) == {:safe, "\\'Single quote\\'"} - end - - describe "html_escape" do - test "escapes entities" do - assert html_escape("foo") == {:safe, "foo"} - assert html_escape("") == {:safe, [[[] | "<"], "foo" | ">"]} - assert html_escape("\" & \'") == {:safe, [[[[] | """], " " | "&"], " " | "'"]} - end - - test "only accepts valid iodata" do - assert html_escape("foo") == {:safe, "foo"} - assert html_escape('foo') == {:safe, 'foo'} - - assert_raise ArgumentError, ~r/templates only support iodata/, fn -> - html_escape('foo🐥') - end - end - end - - describe "attributes_escape" do - test "key as atom" do - assert attributes_escape([{:title, "the title"}]) |> safe_to_string() == - ~s( title="the title") - end - - test "key as string" do - assert attributes_escape([{"title", "the title"}]) |> safe_to_string() == - ~s( title="the title") - end - - test "convert snake_case keys into kebab-case when key is atom" do - assert attributes_escape([{:my_attr, "value"}]) |> safe_to_string() == ~s( my-attr="value") - end - - test "keep snake_case keys when key is string" do - assert attributes_escape([{"my_attr", "value"}]) |> safe_to_string() == ~s( my_attr="value") - end - - test "multiple attributes" do - assert attributes_escape([{:title, "the title"}, {:id, "the id"}]) |> safe_to_string() == - ~s( title="the title" id="the id") - end - - test "handle nested data" do - assert attributes_escape([{"data", [{"a", "1"}, {"b", "2"}]}]) |> safe_to_string() == - ~s( data-a="1" data-b="2") - - assert attributes_escape([{:data, [a: "1", b: "2"]}]) |> safe_to_string() == - ~s( data-a="1" data-b="2") - - assert attributes_escape([{"aria", [{"a", "1"}, {"b", "2"}]}]) |> safe_to_string() == - ~s( aria-a="1" aria-b="2") - - assert attributes_escape([{:aria, [a: "1", b: "2"]}]) |> safe_to_string() == - ~s( aria-a="1" aria-b="2") - - assert attributes_escape([{:phx, [click: "save", value: [user_id: 1, foo: :bar]]}]) - |> safe_to_string() == - ~s( phx-click="save" phx-value-user-id="1" phx-value-foo="bar") - - assert attributes_escape([ - {"phx", [{"click", "save"}, {"value", [{"user_id", 1}, {"foo", "bar"}]}]} - ]) - |> safe_to_string() == - ~s( phx-click="save" phx-value-user_id="1" phx-value-foo="bar") - end - - test "handle class value as string" do - assert attributes_escape([{:class, "btn"}]) |> safe_to_string() == ~s( class="btn") - - assert attributes_escape([{:class, ""}]) |> safe_to_string() == - ~s( class="<active>") - end - - test "handle class value as list" do - assert attributes_escape([{:class, ["btn", nil, false, ""]}]) |> safe_to_string() == - ~s( class="btn <active>") - end - - test "handle class value as false/nil/true" do - assert attributes_escape([{:class, false}]) |> safe_to_string() == ~s() - assert attributes_escape([{:class, nil}]) |> safe_to_string() == ~s() - assert attributes_escape([{:class, true}]) |> safe_to_string() == ~s( class) - end - - test "handle class key as string" do - assert attributes_escape([{"class", "btn"}]) |> safe_to_string() == ~s( class="btn") - end - - test "raises on number id" do - assert_raise ArgumentError, ~r/attempting to set id attribute to 3/, fn -> - attributes_escape([{"id", 3}]) - end - end - - test "suppress attribute when value is falsy" do - assert attributes_escape([{"title", nil}]) |> safe_to_string() == ~s() - assert attributes_escape([{"title", false}]) |> safe_to_string() == ~s() - end - - test "suppress value when value is true" do - assert attributes_escape([{"selected", true}]) |> safe_to_string() == ~s( selected) - end - end end