From 0049ecfd145db671e6ba38f3613b13ba354a56dc Mon Sep 17 00:00:00 2001 From: nafey7 Date: Sun, 12 Jul 2026 23:11:39 +0500 Subject: [PATCH] Validate `page` param in Stats API breakdown endpoint The public Stats API `/api/v1/stats/breakdown` endpoint passed the `page` query parameter directly into `String.to_integer/1`, which raises `ArgumentError` on any non-numeric input, resulting in a 500 Internal Server Error. This mirrors the validation already applied to the sibling `limit` parameter: invalid values now return a clean 400 response with a helpful message instead of crashing. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +++ .../api/external_stats_controller.ex | 15 ++++++-- .../breakdown_test.exs | 35 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a580d8ca5e74..0c54aec18843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ All notable changes to this project will be documented in this file. - Always compare against the same time range in comparisons with "Today" - Added vertical indicator line to graph to make it easier to see what's hovered / selected +### Fixed + +- Stats API `/api/v1/stats/breakdown` now returns a `400` error for an invalid `page` parameter instead of crashing with a `500` + ### Removed - Removed the standalone team switcher page; team switching is now done from the topbar dropdown only diff --git a/lib/plausible_web/controllers/api/external_stats_controller.ex b/lib/plausible_web/controllers/api/external_stats_controller.ex index 50cebddd70dc..7133f870e063 100644 --- a/lib/plausible_web/controllers/api/external_stats_controller.ex +++ b/lib/plausible_web/controllers/api/external_stats_controller.ex @@ -40,9 +40,8 @@ defmodule PlausibleWeb.Api.ExternalStatsController do :ok <- validate_filters(site, query.filters), {:ok, metrics} <- parse_and_validate_metrics(params, query), {:ok, limit} <- validate_or_default_limit(params), + {:ok, page} <- validate_or_default_page(params), :ok <- ensure_custom_props_access(site, query) do - page = String.to_integer(Map.get(params, "page", "1")) - %{results: results, meta: meta} = Legacy.Breakdown.breakdown(site, query, metrics, {limit, page}) @@ -88,6 +87,18 @@ defmodule PlausibleWeb.Api.ExternalStatsController do @default_breakdown_limit 100 defp validate_or_default_limit(_), do: {:ok, @default_breakdown_limit} + defp validate_or_default_page(%{"page" => page}) do + case Integer.parse(page) do + {page, ""} when page > 0 -> + {:ok, page} + + _ -> + {:error, "Please provide page as a number greater than 0."} + end + end + + defp validate_or_default_page(_), do: {:ok, 1} + defp parse_and_validate_metrics(params, query) do metrics = Map.get(params, "metrics", "visitors") diff --git a/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs b/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs index b13b94870e44..3d2b30817d59 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs @@ -2492,6 +2492,41 @@ defmodule PlausibleWeb.Api.ExternalStatsController.BreakdownTest do assert json_response(conn, 400) == %{"error" => @invalid_limit_message} end + @invalid_page_message "Please provide page as a number greater than 0." + + test "returns error with non-integer page", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "page" => "bad_page" + }) + + assert json_response(conn, 400) == %{"error" => @invalid_page_message} + end + + test "returns error with negative integer page", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "page" => -1 + }) + + assert json_response(conn, 400) == %{"error" => @invalid_page_message} + end + + test "returns error with zero page", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "page" => 0 + }) + + assert json_response(conn, 400) == %{"error" => @invalid_page_message} + end + test "can paginate results", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 00:00:00]),