fix(api): isolate sensitive idempotency responses

This commit is contained in:
Lain Soykaf 2026-07-24 22:45:50 +04:00
commit 3b3aad6065
No known key found for this signature in database
4 changed files with 231 additions and 12 deletions

View file

@ -238,6 +238,51 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do
assert Repo.aggregate(Pleroma.PasswordResetToken, :count) == 0
end
test "Password reset links are not cached by idempotency key", %{conn: conn} do
idempotency_key = Ecto.UUID.generate()
request = fn conn ->
conn
|> put_req_header("accept", "application/json")
|> put_req_header("content-type", "application/json")
|> put_req_header("idempotency-key", idempotency_key)
|> post("/api/pleroma/admin/users", %{
"users" => [%{"nickname" => "lain", "email" => "lain@example.org"}]
})
end
assert [%{"data" => %{"password_reset_link" => _link}}] =
conn |> request.() |> json_response(200)
second_conn = request.(conn)
assert [_error] = json_response(second_conn, 409)
refute get_resp_header(second_conn, "idempotent-replayed") == ["true"]
end
test "Explicit-password responses remain idempotent", %{conn: conn} do
idempotency_key = Ecto.UUID.generate()
request = fn conn ->
conn
|> put_req_header("accept", "application/json")
|> put_req_header("content-type", "application/json")
|> put_req_header("idempotency-key", idempotency_key)
|> post("/api/pleroma/admin/users", %{
"users" => [
%{"nickname" => "lain", "email" => "lain@example.org", "password" => "test"}
]
})
end
assert [_account] = conn |> request.() |> json_response(200)
second_conn = request.(conn)
assert [_account] = json_response(second_conn, 200)
assert get_resp_header(second_conn, "idempotent-replayed") == ["true"]
end
test "Duplicate nicknames in a batch return a conflict", %{conn: conn} do
conn =
conn

View file

@ -70,6 +70,139 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlugTest do
assert [^key] = Conn.get_resp_header(conn, "idempotency-key")
end
test "does not cache responses marked as sensitive" do
key = Ecto.UUID.generate()
:post
|> conn("/cofe")
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", "sensitive-request")
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
|> Conn.put_private(:skip_idempotency_cache, true)
|> Conn.send_resp(200, "secret")
conn =
:post
|> conn("/cofe")
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", "second-request")
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
refute conn.halted
refute conn.resp_body
end
test "does not replay legacy unscoped cache entries" do
key = Ecto.UUID.generate()
record = {"legacy-request", "application/json", 200, "secret"}
assert {:ok, true} = Cachex.put(:idempotency_cache, key, record)
conn =
:post
|> conn("/cofe")
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", "new-request")
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
refute conn.halted
refute conn.resp_body
end
test "scopes cached responses to the route and authenticated actor" do
key = Ecto.UUID.generate()
:post
|> conn("/cofe")
|> Conn.assign(:user, %{id: "user-one", is_admin: false, is_moderator: false})
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", "original-request")
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
|> Conn.send_resp(200, "one")
for {path, user} <- [
{"/tea", %{id: "user-one", is_admin: false, is_moderator: false}},
{"/cofe", %{id: "user-two", is_admin: false, is_moderator: false}},
{"/cofe", %{id: "user-one", is_admin: true, is_moderator: false}}
] do
conn =
:post
|> conn(path)
|> Conn.assign(:user, user)
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", Ecto.UUID.generate())
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
refute conn.halted
refute conn.resp_body
end
end
test "scopes cached responses to the OAuth application and token" do
key = Ecto.UUID.generate()
:post
|> conn("/cofe")
|> Conn.assign(:app, %{id: "app-one"})
|> Conn.assign(:token, %{id: "token-one", scopes: ["write"]})
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", "original-request")
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
|> Conn.send_resp(200, "one")
for {app, token} <- [
{%{id: "app-two"}, %{id: "token-two", scopes: ["write"]}},
{%{id: "app-one"}, %{id: "token-two", scopes: ["write"]}},
{%{id: "app-one"}, %{id: "token-one", scopes: ["read"]}}
] do
conn =
:post
|> conn("/cofe")
|> Conn.assign(:app, app)
|> Conn.assign(:token, token)
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", Ecto.UUID.generate())
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
refute conn.halted
refute conn.resp_body
end
end
test "scopes cached responses to configured staff privileges" do
key = Ecto.UUID.generate()
clear_config([:instance, :admin_privileges], [:users_delete])
:post
|> conn("/cofe")
|> Conn.assign(:user, %{id: "admin", is_admin: true, is_moderator: false})
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", "original-request")
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
|> Conn.send_resp(200, "one")
clear_config([:instance, :admin_privileges], [])
conn =
:post
|> conn("/cofe")
|> Conn.assign(:user, %{id: "admin", is_admin: true, is_moderator: false})
|> put_req_header("idempotency-key", key)
|> Conn.put_resp_header("x-request-id", "second-request")
|> Conn.put_resp_content_type("application/json")
|> IdempotencyPlug.call([])
refute conn.halted
refute conn.resp_body
end
test "passes conn downstream if idempotency is not present in headers" do
orig_request_id = "test4"
body = "testing"