Validate password cookie for password-protected shared links on internal stats API requests (#5932)

* Works

* Move shared link password check to AuthorizeSiteAccess plug

* Write changelog, cleanup

* Handle cookies already fetched in AuthorizeSiteAccess

* Unify shared link kind with plugins API entity
This commit is contained in:
Artur Pata 2025-12-08 09:05:31 +02:00 committed by GitHub
parent 1ff2b52cbb
commit 007155ba60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 134 additions and 28 deletions

View File

@ -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

View File

@ -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

View File

@ -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)
{: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
{:unlisted, shared_link} ->
render_shared_link(conn, shared_link)
:not_found ->
{: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

View File

@ -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)
}
}

View File

@ -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}

View File

@ -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