Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ All notable changes to this project will be documented in this file.
### Changed

- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards
- When accessing a link to a shared password-protected dashboard subpage (e.g. `.../pages`), the viewer will be redirected to that subpage after providing the password

### Fixed

Expand Down
79 changes: 70 additions & 9 deletions lib/plausible_web/controllers/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ defmodule PlausibleWeb.StatsController do
)

if shared_link do
new_link_format = Routes.stats_path(conn, :shared_link, shared_link.site.domain, auth: slug)
new_link_format =
Routes.stats_path(conn, :shared_link, shared_link.site.domain, [], auth: slug)

redirect(conn, to: new_link_format)
else
render_error(conn, 404)
Expand All @@ -306,6 +308,16 @@ defmodule PlausibleWeb.StatsController do
defp render_password_protected_shared_link(conn, shared_link) do
conn = Plug.Conn.fetch_cookies(conn)

# discard untrustworthy return_to given from query params
trimmed_query_string = conn.query_string |> omit_from_query_string("return_to")
star_path_fragment = serialize_star_path_as_query_string_fragment(conn)

# set valid return_to if star path is set
query_string =
[trimmed_query_string, star_path_fragment]
|> Enum.filter(fn v -> is_binary(v) and String.length(v) > 0 end)
|> Enum.join("&")

case validate_shared_link_password(conn, shared_link) do
{:ok, shared_link} ->
render_shared_link(conn, shared_link)
Expand All @@ -314,7 +326,7 @@ defmodule PlausibleWeb.StatsController do
conn
|> render("shared_link_password.html",
link: shared_link,
query_string: conn.query_string,
query_string: query_string,
dogfood_page_path: "/share/:dashboard"
)
end
Expand Down Expand Up @@ -349,12 +361,29 @@ defmodule PlausibleWeb.StatsController do
if Plausible.Auth.Password.match?(password, shared_link.password_hash) do
token = Plausible.Auth.Token.sign_shared_link(slug)

star_path = parse_star_path(conn)
Comment thread
zoldar marked this conversation as resolved.

# The filter query params format used by the FE breaks when it passes through Phoenix / Plug.Conn decode/encode.
# This function works around that by using the original query string.
query_string_fragment =
get_rest_of_query_string(conn)
Comment thread
zoldar marked this conversation as resolved.
# omitted because return_to param was needed only for this function
|> omit_from_query_string("return_to")
# omitted because `auth: slug` query param is set definitively below
|> omit_from_query_string("auth")
Comment thread
zoldar marked this conversation as resolved.

conn
|> put_resp_cookie(shared_link_cookie_name(slug), token)
|> redirect(
to:
Routes.stats_path(conn, :shared_link, shared_link.site.domain, auth: slug) <>
qs_appendix(conn)
Routes.stats_path(
conn,
:shared_link,
shared_link.site.domain,
star_path,
auth: slug
) <>
query_string_fragment
)
else
conn
Expand All @@ -370,12 +399,44 @@ defmodule PlausibleWeb.StatsController do
end
end

def qs_appendix(conn)
when is_nil(conn.query_string) or
(is_binary(conn.query_string) and byte_size(conn.query_string)) == 0,
do: ""
defp serialize_star_path_as_query_string_fragment(conn) do
star_path = conn.path_params["path"]

if length(star_path) > 0 do
# make the path start with a /
# to be able to reject values that don't start with a /
%{"return_to" => "/#{Enum.join(star_path, "/")}"} |> URI.encode_query()
else
nil
end
end

defp parse_star_path(conn) do
case conn.query_params["return_to"] do
# omit prefix added in `serialize_star_path_as_query_string_fragment`
"/" <> return_to ->
return_to
|> String.split("/")
# disallow constructing links that navigate up
|> Enum.filter(fn part -> part !== ".." end)

def qs_appendix(conn), do: "&#{conn.query_string}"
_ ->
[]
end
end

defp get_rest_of_query_string(conn) when conn.query_string in [nil, ""], do: ""

defp get_rest_of_query_string(conn), do: "&#{conn.query_string}"
Comment thread
zoldar marked this conversation as resolved.

defp omit_from_query_string(query_string, key) do
query_string
|> String.split("&")
|> Enum.reject(fn key_and_value ->
key_and_value == key || String.starts_with?(key_and_value, "#{key}=")
end)
|> Enum.join("&")
end

defp render_shared_link(conn, shared_link) do
shared_links_feature_access? =
Expand Down
2 changes: 1 addition & 1 deletion lib/plausible_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ defmodule PlausibleWeb.Router do
scope "/", PlausibleWeb do
pipe_through [:shared_link]

get "/share/:domain", StatsController, :shared_link
get "/share/:domain/*path", StatsController, :shared_link
post "/share/:slug/authenticate", StatsController, :authenticate_shared_link
end

Expand Down
149 changes: 114 additions & 35 deletions test/plausible_web/controllers/stats_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1528,7 +1528,7 @@ defmodule PlausibleWeb.StatsControllerTest do
site_link = insert(:shared_link, site: site, inserted_at: ~N[2021-12-31 00:00:00])

conn = get(conn, "/share/#{site_link.slug}")
assert redirected_to(conn, 302) == "/share/#{site.domain}?auth=#{site_link.slug}"
assert redirected_to(conn, 302) == "/share/#{site.domain}/?auth=#{site_link.slug}"
end

test "it does nothing for newer links", %{conn: conn} do
Expand All @@ -1548,7 +1548,7 @@ defmodule PlausibleWeb.StatsControllerTest do
insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password"))

conn = post(conn, "/share/#{link.slug}/authenticate", %{password: "password"})
assert redirected_to(conn, 302) == "/share/#{site.domain}?auth=#{link.slug}"
assert redirected_to(conn, 302) == "/share/#{site.domain}/?auth=#{link.slug}"

conn = get(conn, "/share/#{site.domain}?auth=#{link.slug}")
assert html_response(conn, 200) =~ "stats-react-container"
Expand Down Expand Up @@ -1578,7 +1578,7 @@ defmodule PlausibleWeb.StatsControllerTest do
)

conn = post(conn, "/share/#{link.slug}/authenticate", %{password: "password"})
assert redirected_to(conn, 302) == "/share/#{site.domain}?auth=#{link.slug}"
assert redirected_to(conn, 302) == "/share/#{site.domain}/?auth=#{link.slug}"
Comment thread
ukutaht marked this conversation as resolved.

conn = get(conn, "/share/#{site2.domain}?auth=#{link2.slug}")
assert html_response(conn, 200) =~ "Enter password"
Expand All @@ -1595,68 +1595,147 @@ defmodule PlausibleWeb.StatsControllerTest do
conn =
get(
conn,
"/share/#{site.domain}?auth=#{link.slug}&#{filters}"
"/share/#{URI.encode_www_form(site.domain)}?auth=#{link.slug}&#{filters}"
)

assert html_response(conn, 200) =~ "Enter password"
html = html_response(conn, 200)

assert html =~ ~s(action="/share/#{link.slug}/authenticate?)
assert html =~ "f=is,browser,Firefox"
assert html =~ "f=is,country,EE"
assert html =~ "l=EE,Estonia"
expected_action_string =
"/share/#{URI.encode_www_form(link.slug)}/authenticate?auth=#{link.slug}&#{filters}"

conn =
post(
conn,
"/share/#{link.slug}/authenticate?#{filters}",
%{password: "password"}
)

expected_redirect =
"/share/#{URI.encode_www_form(site.domain)}?auth=#{link.slug}&#{filters}"

assert redirected_to(conn, 302) == expected_redirect
assert text_of_attr(html, "form", "action") == expected_action_string

conn =
post(
conn,
"/share/#{link.slug}/authenticate?#{filters}",
expected_action_string,
%{password: "WRONG!"}
)

html = html_response(conn, 200)
assert html =~ "Enter password"
assert html =~ "Incorrect password"

assert text_of_attr(html, "form", "action") =~ "?#{filters}"
assert text_of_attr(html, "form", "action") == expected_action_string

conn =
post(
conn,
"/share/#{link.slug}/authenticate?#{filters}",
expected_action_string,
%{password: "password"}
)

redirected_url = redirected_to(conn, 302)
assert redirected_url =~ filters

conn =
post(
conn,
"/share/#{link.slug}/authenticate?#{filters}",
%{password: "password"}
)
expected_redirect =
"/share/#{URI.encode_www_form(site.domain)}/?auth=#{link.slug}&#{filters}"

redirect_path = redirected_to(conn, 302)
assert redirected_to(conn, 302) == expected_redirect

conn = get(conn, redirect_path)
conn = get(conn, expected_redirect)
assert html_response(conn, 200) =~ "stats-react-container"
assert redirect_path =~ filters
assert redirect_path =~ "auth=#{link.slug}"
end
end

test "handles return_to during password authentication", %{conn: conn} do
site = new_site()

link =
insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password"))

filters = "f=is,country,EE&l=EE,Estonia&f=is,browser,Firefox"

deep_path = "/filter/source"

conn =
get(
conn,
"/share/#{URI.encode_www_form(site.domain)}#{deep_path}?auth=#{link.slug}&#{filters}"
)

assert html_response(conn, 200) =~ "Enter password"
html = html_response(conn, 200)

expected_action_string =
"/share/#{link.slug}/authenticate?auth=#{link.slug}&#{filters}&#{URI.encode_query(%{"return_to" => deep_path})}"

assert text_of_attr(html, "form", "action") == expected_action_string

conn =
post(
conn,
expected_action_string,
%{password: "password"}
)

assert redirected_to(conn, 302) ==
"/share/#{URI.encode_www_form(site.domain)}#{deep_path}?auth=#{link.slug}&#{filters}"
end

test "return_to from query_params is discarded", %{conn: conn} do
site = new_site()

link =
insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password"))

conn =
get(
conn,
"/share/#{URI.encode_www_form(site.domain)}/pages?auth=#{link.slug}&return_to=%2Ffoobar"
)

assert html_response(conn, 200) =~ "Enter password"
html = html_response(conn, 200)

expected_action_string =
"/share/#{link.slug}/authenticate?auth=#{link.slug}&return_to=%2Fpages"

assert text_of_attr(html, "form", "action") == expected_action_string

conn =
post(
conn,
expected_action_string,
%{password: "password"}
)

assert redirected_to(conn, 302) ==
"/share/#{URI.encode_www_form(site.domain)}/pages?auth=#{link.slug}"
end

test "return_to doesn't allow navigating out of dashboard context", %{conn: conn} do
site = new_site()

link =
insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password"))

deep_path = "/../../settings/api-keys"
cleaned_deep_path = "/settings/api-keys"

conn =
get(
conn,
"/share/#{URI.encode_www_form(site.domain)}#{deep_path}?auth=#{link.slug}&theme=dark"
)

assert html_response(conn, 200) =~ "Enter password"
html = html_response(conn, 200)

expected_action_string =
"/share/#{link.slug}/authenticate?auth=#{link.slug}&theme=dark&#{URI.encode_query(%{"return_to" => deep_path})}"

assert text_of_attr(html, "form", "action") == expected_action_string

conn =
post(
conn,
expected_action_string,
%{password: "password"}
)

assert redirected_to(conn, 302) ==
"/share/#{URI.encode_www_form(site.domain)}#{cleaned_deep_path}?auth=#{link.slug}&theme=dark"
end

describe "dogfood tracking" do
@describetag :ee_only

Expand Down
Loading