Skip to content

Commit 007155b

Browse files
authored
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
1 parent 1ff2b52 commit 007155b

6 files changed

Lines changed: 135 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file.
1212

1313
### Fixed
1414

15+
- To make internal stats API requests for password-protected shared links, shared link auth cookie must be set in the requests
16+
1517
## v3.1.0 - 2025-11-13
1618

1719
### Added

lib/plausible/site/shared_link.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,7 @@ defmodule Plausible.Site.SharedLink do
4545
change(link, password_hash: hash)
4646
end
4747
end
48+
49+
def password_protected?(%__MODULE__{password_hash: hash}) when not is_nil(hash), do: true
50+
def password_protected?(%__MODULE__{}), do: false
4851
end

lib/plausible_web/controllers/stats_controller.ex

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,14 @@ defmodule PlausibleWeb.StatsController do
258258
"""
259259
def shared_link(conn, %{"domain" => domain, "auth" => auth}) do
260260
case find_shared_link(domain, auth) do
261-
{:password_protected, shared_link} ->
262-
render_password_protected_shared_link(conn, shared_link)
263-
264-
{:unlisted, shared_link} ->
265-
render_shared_link(conn, shared_link)
266-
267-
:not_found ->
261+
{:ok, shared_link} ->
262+
if Plausible.Site.SharedLink.password_protected?(shared_link) do
263+
render_password_protected_shared_link(conn, shared_link)
264+
else
265+
render_shared_link(conn, shared_link)
266+
end
267+
268+
{:error, :not_found} ->
268269
render_error(conn, 404)
269270
end
270271
end
@@ -291,14 +292,24 @@ defmodule PlausibleWeb.StatsController do
291292
render_error(conn, 400)
292293
end
293294

294-
defp render_password_protected_shared_link(conn, shared_link) do
295-
with conn <- Plug.Conn.fetch_cookies(conn),
296-
{:ok, token} <- Map.fetch(conn.req_cookies, shared_link_cookie_name(shared_link.slug)),
295+
def validate_shared_link_password(conn, shared_link) do
296+
with {:ok, token} <- Map.fetch(conn.req_cookies, shared_link_cookie_name(shared_link.slug)),
297297
{:ok, %{slug: token_slug}} <- Plausible.Auth.Token.verify_shared_link(token),
298298
true <- token_slug == shared_link.slug do
299-
render_shared_link(conn, shared_link)
299+
{:ok, shared_link}
300300
else
301-
_e ->
301+
_e -> {:error, :unauthorized}
302+
end
303+
end
304+
305+
defp render_password_protected_shared_link(conn, shared_link) do
306+
conn = Plug.Conn.fetch_cookies(conn)
307+
308+
case validate_shared_link_password(conn, shared_link) do
309+
{:ok, shared_link} ->
310+
render_shared_link(conn, shared_link)
311+
312+
_ ->
302313
conn
303314
|> render("shared_link_password.html",
304315
link: shared_link,
@@ -320,14 +331,11 @@ defmodule PlausibleWeb.StatsController do
320331
)
321332

322333
case Repo.one(link_query) do
323-
%Plausible.Site.SharedLink{password_hash: hash} = link when not is_nil(hash) ->
324-
{:password_protected, link}
325-
326334
%Plausible.Site.SharedLink{} = link ->
327-
{:unlisted, link}
335+
{:ok, link}
328336

329337
nil ->
330-
:not_found
338+
{:error, :not_found}
331339
end
332340
end
333341

lib/plausible_web/plugins/api/views/shared_link.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ defmodule PlausibleWeb.Plugins.API.Views.SharedLink do
2929
shared_link: %{
3030
id: shared_link.id,
3131
name: shared_link.name,
32-
password_protected: is_binary(shared_link.password_hash),
32+
password_protected: Plausible.Site.SharedLink.password_protected?(shared_link),
3333
href: Plausible.Sites.shared_link_url(site, shared_link)
3434
}
3535
}

lib/plausible_web/plugs/authorize_site_access.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,18 @@ defmodule PlausibleWeb.Plugs.AuthorizeSiteAccess do
201201
slug = conn.path_params["slug"] || conn.params["auth"]
202202

203203
if valid_path_fragment?(slug) do
204-
if shared_link = Repo.get_by(Plausible.Site.SharedLink, slug: slug, site_id: site.id) do
204+
with %Plausible.Site.SharedLink{} = shared_link <-
205+
Repo.get_by(Plausible.Site.SharedLink, slug: slug, site_id: site.id),
206+
{%{password_protected?: true}, shared_link} <-
207+
{%{password_protected?: Plausible.Site.SharedLink.password_protected?(shared_link)},
208+
shared_link},
209+
{:ok, shared_link} <-
210+
PlausibleWeb.StatsController.validate_shared_link_password(conn, shared_link) do
205211
{:ok, shared_link}
206212
else
207-
error_not_found(conn)
213+
{%{password_protected?: false}, shared_link} -> {:ok, shared_link}
214+
{:error, :unauthorized} -> error_not_found(conn)
215+
nil -> error_not_found(conn)
208216
end
209217
else
210218
{:ok, nil}

test/plausible_web/controllers/api/stats_controller/authorization_test.exs

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do
2-
use PlausibleWeb.ConnCase
2+
use PlausibleWeb.ConnCase, async: true
33

44
describe "API authorization - as anonymous user" do
5-
test "Sends 404 Not found for a site that doesn't exist", %{conn: conn} do
5+
test "returns 404 for a site that doesn't exist", %{conn: conn} do
66
conn = init_session(conn)
77
conn = get(conn, "/api/stats/fake-site.com/main-graph")
88

9-
assert conn.status == 404
9+
assert json_response(conn, 404) == %{
10+
"error" => "Site does not exist or user does not have sufficient access."
11+
}
1012
end
1113

12-
test "Sends 404 Not found for private site", %{conn: conn} do
14+
test "returns 404 for private site", %{conn: conn} do
1315
conn = init_session(conn)
1416
site = insert(:site, public: false)
1517
conn = get(conn, "/api/stats/#{site.domain}/main-graph")
1618

17-
assert conn.status == 404
19+
assert json_response(conn, 404) == %{
20+
"error" => "Site does not exist or user does not have sufficient access."
21+
}
1822
end
1923

2024
test "returns stats for public site", %{conn: conn} do
@@ -26,21 +30,102 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do
2630
end
2731
end
2832

33+
describe "API authorization for shared links - as anonymous user" do
34+
test "returns 404 for non-existent shared link", %{conn: conn} do
35+
site = new_site()
36+
37+
conn = get(conn, "/api/stats/#{site.domain}/top-stats?auth=does-not-exist")
38+
39+
assert json_response(conn, 404) == %{
40+
"error" => "Site does not exist or user does not have sufficient access."
41+
}
42+
end
43+
44+
test "returns 200 for unlisted shared link without cookie", %{conn: conn} do
45+
site = new_site()
46+
link = insert(:shared_link, site: site)
47+
48+
conn = get(conn, "/api/stats/#{site.domain}/top-stats?auth=#{link.slug}")
49+
50+
assert %{"top_stats" => _any} = json_response(conn, 200)
51+
end
52+
53+
test "returns 200 for password-protected link with valid cookie", %{conn: conn} do
54+
site = new_site()
55+
56+
link =
57+
insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password"))
58+
59+
token = Plausible.Auth.Token.sign_shared_link(link.slug)
60+
cookie_name = "shared-link-" <> link.slug
61+
62+
conn =
63+
conn
64+
|> put_req_cookie(cookie_name, token)
65+
|> get("/api/stats/#{site.domain}/top-stats?auth=#{link.slug}")
66+
67+
assert %{"top_stats" => _any} = json_response(conn, 200)
68+
end
69+
70+
test "returns 404 for password-protected link with invalid cookie value", %{conn: conn} do
71+
site = new_site()
72+
73+
link =
74+
insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password"))
75+
76+
other_link =
77+
insert(:shared_link,
78+
name: "other link",
79+
site: site,
80+
password_hash: Plausible.Auth.Password.hash("password")
81+
)
82+
83+
other_link_token = Plausible.Auth.Token.sign_shared_link(other_link.slug)
84+
cookie_name = "shared-link-" <> link.slug
85+
86+
conn =
87+
conn
88+
|> put_req_cookie(cookie_name, other_link_token)
89+
|> get("/api/stats/#{site.domain}/top-stats?auth=#{link.slug}")
90+
91+
assert json_response(conn, 404) == %{
92+
"error" => "Site does not exist or user does not have sufficient access."
93+
}
94+
end
95+
96+
test "returns 404 for password-protected link without cookie", %{conn: conn} do
97+
site = new_site()
98+
99+
link =
100+
insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password"))
101+
102+
conn = get(conn, "/api/stats/#{site.domain}/top-stats?auth=#{link.slug}")
103+
104+
assert json_response(conn, 404) == %{
105+
"error" => "Site does not exist or user does not have sufficient access."
106+
}
107+
end
108+
end
109+
29110
describe "API authorization - as logged in user" do
30111
setup [:create_user, :log_in]
31112

32-
test "Sends 404 Not found for a site that doesn't exist", %{conn: conn} do
113+
test "returns 404 for a site that doesn't exist", %{conn: conn} do
33114
conn = init_session(conn)
34115
conn = get(conn, "/api/stats/fake-site.com/main-graph/")
35116

36-
assert conn.status == 404
117+
assert json_response(conn, 404) == %{
118+
"error" => "Site does not exist or user does not have sufficient access."
119+
}
37120
end
38121

39-
test "Sends 404 Not found when user does not have access to site", %{conn: conn} do
122+
test "returns 404 when user does not have access to site", %{conn: conn} do
40123
site = new_site()
41124
conn = get(conn, "/api/stats/#{site.domain}/main-graph")
42125

43-
assert conn.status == 404
126+
assert json_response(conn, 404) == %{
127+
"error" => "Site does not exist or user does not have sufficient access."
128+
}
44129
end
45130

46131
test "returns stats for public site", %{conn: conn} do

0 commit comments

Comments
 (0)