diff --git a/CHANGELOG.md b/CHANGELOG.md index 65cdc1102d54..de5252c4bb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file. ### Fixed +- To make internal stats API requests for password-protected shared links, shared link auth cookie must be set in the requests + ## v3.1.0 - 2025-11-13 ### Added diff --git a/lib/plausible/site/shared_link.ex b/lib/plausible/site/shared_link.ex index fdc8109a5962..2a2c2cf7f2f9 100644 --- a/lib/plausible/site/shared_link.ex +++ b/lib/plausible/site/shared_link.ex @@ -45,4 +45,7 @@ defmodule Plausible.Site.SharedLink do change(link, password_hash: hash) end end + + def password_protected?(%__MODULE__{password_hash: hash}) when not is_nil(hash), do: true + def password_protected?(%__MODULE__{}), do: false end diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 07dc1412b10c..a1c8bc5bc7ed 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -258,13 +258,14 @@ defmodule PlausibleWeb.StatsController do """ def shared_link(conn, %{"domain" => domain, "auth" => auth}) do case find_shared_link(domain, auth) do - {:password_protected, shared_link} -> - render_password_protected_shared_link(conn, shared_link) - - {:unlisted, shared_link} -> - render_shared_link(conn, shared_link) - - :not_found -> + {:ok, shared_link} -> + if Plausible.Site.SharedLink.password_protected?(shared_link) do + render_password_protected_shared_link(conn, shared_link) + else + render_shared_link(conn, shared_link) + end + + {:error, :not_found} -> render_error(conn, 404) end end @@ -291,14 +292,24 @@ defmodule PlausibleWeb.StatsController do render_error(conn, 400) end - defp render_password_protected_shared_link(conn, shared_link) do - with conn <- Plug.Conn.fetch_cookies(conn), - {:ok, token} <- Map.fetch(conn.req_cookies, shared_link_cookie_name(shared_link.slug)), + def validate_shared_link_password(conn, shared_link) do + with {:ok, token} <- Map.fetch(conn.req_cookies, shared_link_cookie_name(shared_link.slug)), {:ok, %{slug: token_slug}} <- Plausible.Auth.Token.verify_shared_link(token), true <- token_slug == shared_link.slug do - render_shared_link(conn, shared_link) + {:ok, shared_link} else - _e -> + _e -> {:error, :unauthorized} + end + end + + defp render_password_protected_shared_link(conn, shared_link) do + conn = Plug.Conn.fetch_cookies(conn) + + case validate_shared_link_password(conn, shared_link) do + {:ok, shared_link} -> + render_shared_link(conn, shared_link) + + _ -> conn |> render("shared_link_password.html", link: shared_link, @@ -320,14 +331,11 @@ defmodule PlausibleWeb.StatsController do ) case Repo.one(link_query) do - %Plausible.Site.SharedLink{password_hash: hash} = link when not is_nil(hash) -> - {:password_protected, link} - %Plausible.Site.SharedLink{} = link -> - {:unlisted, link} + {:ok, link} nil -> - :not_found + {:error, :not_found} end end diff --git a/lib/plausible_web/plugins/api/views/shared_link.ex b/lib/plausible_web/plugins/api/views/shared_link.ex index 808fb26839b9..f007055faea6 100644 --- a/lib/plausible_web/plugins/api/views/shared_link.ex +++ b/lib/plausible_web/plugins/api/views/shared_link.ex @@ -29,7 +29,7 @@ defmodule PlausibleWeb.Plugins.API.Views.SharedLink do shared_link: %{ id: shared_link.id, name: shared_link.name, - password_protected: is_binary(shared_link.password_hash), + password_protected: Plausible.Site.SharedLink.password_protected?(shared_link), href: Plausible.Sites.shared_link_url(site, shared_link) } } diff --git a/lib/plausible_web/plugs/authorize_site_access.ex b/lib/plausible_web/plugs/authorize_site_access.ex index fa22353baa85..063df8258d59 100644 --- a/lib/plausible_web/plugs/authorize_site_access.ex +++ b/lib/plausible_web/plugs/authorize_site_access.ex @@ -201,10 +201,18 @@ defmodule PlausibleWeb.Plugs.AuthorizeSiteAccess do slug = conn.path_params["slug"] || conn.params["auth"] if valid_path_fragment?(slug) do - if shared_link = Repo.get_by(Plausible.Site.SharedLink, slug: slug, site_id: site.id) do + with %Plausible.Site.SharedLink{} = shared_link <- + Repo.get_by(Plausible.Site.SharedLink, slug: slug, site_id: site.id), + {%{password_protected?: true}, shared_link} <- + {%{password_protected?: Plausible.Site.SharedLink.password_protected?(shared_link)}, + shared_link}, + {:ok, shared_link} <- + PlausibleWeb.StatsController.validate_shared_link_password(conn, shared_link) do {:ok, shared_link} else - error_not_found(conn) + {%{password_protected?: false}, shared_link} -> {:ok, shared_link} + {:error, :unauthorized} -> error_not_found(conn) + nil -> error_not_found(conn) end else {:ok, nil} diff --git a/test/plausible_web/controllers/api/stats_controller/authorization_test.exs b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs index d6e85e99b2f1..7457658b759f 100644 --- a/test/plausible_web/controllers/api/stats_controller/authorization_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs @@ -1,20 +1,24 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do - use PlausibleWeb.ConnCase + use PlausibleWeb.ConnCase, async: true describe "API authorization - as anonymous user" do - test "Sends 404 Not found for a site that doesn't exist", %{conn: conn} do + test "returns 404 for a site that doesn't exist", %{conn: conn} do conn = init_session(conn) conn = get(conn, "/api/stats/fake-site.com/main-graph") - assert conn.status == 404 + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } end - test "Sends 404 Not found for private site", %{conn: conn} do + test "returns 404 for private site", %{conn: conn} do conn = init_session(conn) site = insert(:site, public: false) conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert conn.status == 404 + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } end test "returns stats for public site", %{conn: conn} do @@ -26,21 +30,102 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do end end + describe "API authorization for shared links - as anonymous user" do + test "returns 404 for non-existent shared link", %{conn: conn} do + site = new_site() + + conn = get(conn, "/api/stats/#{site.domain}/top-stats?auth=does-not-exist") + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns 200 for unlisted shared link without cookie", %{conn: conn} do + site = new_site() + link = insert(:shared_link, site: site) + + conn = get(conn, "/api/stats/#{site.domain}/top-stats?auth=#{link.slug}") + + assert %{"top_stats" => _any} = json_response(conn, 200) + end + + test "returns 200 for password-protected link with valid cookie", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + token = Plausible.Auth.Token.sign_shared_link(link.slug) + cookie_name = "shared-link-" <> link.slug + + conn = + conn + |> put_req_cookie(cookie_name, token) + |> get("/api/stats/#{site.domain}/top-stats?auth=#{link.slug}") + + assert %{"top_stats" => _any} = json_response(conn, 200) + end + + test "returns 404 for password-protected link with invalid cookie value", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + other_link = + insert(:shared_link, + name: "other link", + site: site, + password_hash: Plausible.Auth.Password.hash("password") + ) + + other_link_token = Plausible.Auth.Token.sign_shared_link(other_link.slug) + cookie_name = "shared-link-" <> link.slug + + conn = + conn + |> put_req_cookie(cookie_name, other_link_token) + |> get("/api/stats/#{site.domain}/top-stats?auth=#{link.slug}") + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns 404 for password-protected link without cookie", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + conn = get(conn, "/api/stats/#{site.domain}/top-stats?auth=#{link.slug}") + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + end + describe "API authorization - as logged in user" do setup [:create_user, :log_in] - test "Sends 404 Not found for a site that doesn't exist", %{conn: conn} do + test "returns 404 for a site that doesn't exist", %{conn: conn} do conn = init_session(conn) conn = get(conn, "/api/stats/fake-site.com/main-graph/") - assert conn.status == 404 + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } end - test "Sends 404 Not found when user does not have access to site", %{conn: conn} do + test "returns 404 when user does not have access to site", %{conn: conn} do site = new_site() conn = get(conn, "/api/stats/#{site.domain}/main-graph") - assert conn.status == 404 + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } end test "returns stats for public site", %{conn: conn} do