Enforcement of OAuth scopes check for authenticated API endpoints, :skip_plug plug to mark a plug explicitly skipped (disabled).

This commit is contained in:
Ivan Tashkinov 2020-04-06 10:20:44 +03:00
parent 8444e7ee96
commit fc81e5a49c
14 changed files with 113 additions and 39 deletions

View File

@ -0,0 +1,13 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.AuthExpectedPlug do
import Plug.Conn
def init(options), do: options
def call(conn, _) do
put_private(conn, :auth_expected, true)
end
end

View File

@ -8,12 +8,15 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
alias Pleroma.Plugs.PlugHelper
@behaviour Plug @behaviour Plug
def init(%{scopes: _} = options), do: options def init(%{scopes: _} = options), do: options
def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do
conn = PlugHelper.append_to_called_plugs(conn, __MODULE__)
op = options[:op] || :| op = options[:op] || :|
token = assigns[:token] token = assigns[:token]

View File

@ -0,0 +1,38 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.PlugHelper do
@moduledoc "Pleroma Plug helper"
def append_to_called_plugs(conn, plug_module) do
append_to_private_list(conn, :called_plugs, plug_module)
end
def append_to_skipped_plugs(conn, plug_module) do
append_to_private_list(conn, :skipped_plugs, plug_module)
end
def plug_called?(conn, plug_module) do
contained_in_private_list?(conn, :called_plugs, plug_module)
end
def plug_skipped?(conn, plug_module) do
contained_in_private_list?(conn, :skipped_plugs, plug_module)
end
def plug_called_or_skipped?(conn, plug_module) do
plug_called?(conn, plug_module) || plug_skipped?(conn, plug_module)
end
defp append_to_private_list(conn, private_variable, value) do
list = conn.private[private_variable] || []
modified_list = Enum.uniq(list ++ [value])
Plug.Conn.put_private(conn, private_variable, modified_list)
end
defp contained_in_private_list?(conn, private_variable, value) do
list = conn.private[private_variable] || []
value in list
end
end

View File

@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastoFEController do
when action == :index when action == :index
) )
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :index) plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [:index, :manifest])
@doc "GET /web/*path" @doc "GET /web/*path"
def index(%{assigns: %{user: user, token: token}} = conn, _params) def index(%{assigns: %{user: user, token: token}} = conn, _params)

View File

@ -15,10 +15,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.ListView
alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonAPI
alias Pleroma.Web.MastodonAPI.MastodonAPIController
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.TwitterAPI.TwitterAPI alias Pleroma.Web.TwitterAPI.TwitterAPI
plug(:skip_plug, OAuthScopesPlug when action == :identity_proofs)
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
@ -369,6 +372,8 @@ def blocks(%{assigns: %{user: user}} = conn, _) do
end end
@doc "GET /api/v1/endorsements" @doc "GET /api/v1/endorsements"
def endorsements(conn, params), def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, params)
do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
@doc "GET /api/v1/identity_proofs"
def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)
end end

View File

@ -3,21 +3,31 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
@moduledoc """
Contains stubs for unimplemented Mastodon API endpoints.
Note: instead of routing directly to this controller's action,
it's preferable to define an action in relevant (non-generic) controller,
set up OAuth rules for it and call this controller's function from it.
"""
use Pleroma.Web, :controller use Pleroma.Web, :controller
require Logger require Logger
plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug when action in [:empty_array, :empty_object])
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
# Stubs for unimplemented mastodon api
#
def empty_array(conn, _) do def empty_array(conn, _) do
Logger.debug("Unimplemented, returning an empty array") Logger.debug("Unimplemented, returning an empty array (list)")
json(conn, []) json(conn, [])
end end
def empty_object(conn, _) do def empty_object(conn, _) do
Logger.debug("Unimplemented, returning an empty object") Logger.debug("Unimplemented, returning an empty object (map)")
json(conn, %{}) json(conn, %{})
end end
end end

View File

@ -5,10 +5,13 @@
defmodule Pleroma.Web.MastodonAPI.SuggestionController do defmodule Pleroma.Web.MastodonAPI.SuggestionController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
require Logger require Logger
plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :index)
@doc "GET /api/v1/suggestions" @doc "GET /api/v1/suggestions"
def index(conn, _) do def index(conn, params),
json(conn, []) do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
end
end end

View File

@ -27,6 +27,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
plug(:fetch_flash) plug(:fetch_flash)
plug(RateLimiter, [name: :authentication] when action == :create_authorization) plug(RateLimiter, [name: :authentication] when action == :create_authorization)
plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug)
action_fallback(Pleroma.Web.OAuth.FallbackController) action_fallback(Pleroma.Web.OAuth.FallbackController)
@oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob" @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob"

View File

@ -34,7 +34,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["write:conversations"]} when action == :update_conversation %{scopes: ["write:conversations"]} when action in [:update_conversation, :read_conversations]
) )
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification) plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification)

View File

@ -34,6 +34,7 @@ defmodule Pleroma.Web.Router do
pipeline :authenticated_api do pipeline :authenticated_api do
plug(:accepts, ["json"]) plug(:accepts, ["json"])
plug(:fetch_session) plug(:fetch_session)
plug(Pleroma.Plugs.AuthExpectedPlug)
plug(Pleroma.Plugs.OAuthPlug) plug(Pleroma.Plugs.OAuthPlug)
plug(Pleroma.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Plugs.BasicAuthDecoderPlug)
plug(Pleroma.Plugs.UserFetcherPlug) plug(Pleroma.Plugs.UserFetcherPlug)
@ -333,7 +334,7 @@ defmodule Pleroma.Web.Router do
get("/accounts/relationships", AccountController, :relationships) get("/accounts/relationships", AccountController, :relationships)
get("/accounts/:id/lists", AccountController, :lists) get("/accounts/:id/lists", AccountController, :lists)
get("/accounts/:id/identity_proofs", MastodonAPIController, :empty_array) get("/accounts/:id/identity_proofs", AccountController, :identity_proofs)
get("/follow_requests", FollowRequestController, :index) get("/follow_requests", FollowRequestController, :index)
get("/blocks", AccountController, :blocks) get("/blocks", AccountController, :blocks)

View File

@ -15,6 +15,8 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read)
plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token])
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
action_fallback(:errors) action_fallback(:errors)

View File

@ -29,11 +29,34 @@ def controller do
import Pleroma.Web.Router.Helpers import Pleroma.Web.Router.Helpers
import Pleroma.Web.TranslationHelpers import Pleroma.Web.TranslationHelpers
alias Pleroma.Plugs.PlugHelper
plug(:set_put_layout) plug(:set_put_layout)
defp set_put_layout(conn, _) do defp set_put_layout(conn, _) do
put_layout(conn, Pleroma.Config.get(:app_layout, "app.html")) put_layout(conn, Pleroma.Config.get(:app_layout, "app.html"))
end end
# Marks a plug as intentionally skipped
# (states that the plug is not called for a good reason, not by a mistake)
defp skip_plug(conn, plug_module) do
PlugHelper.append_to_skipped_plugs(conn, plug_module)
end
# Here we can apply before-action hooks (e.g. verify whether auth checks were preformed)
defp action(conn, params) do
if conn.private[:auth_expected] &&
not PlugHelper.plug_called_or_skipped?(conn, Pleroma.Plugs.OAuthScopesPlug) do
conn
|> render_error(
:forbidden,
"Security violation: OAuth scopes check was neither handled nor explicitly skipped."
)
|> halt()
else
super(conn, params)
end
end
end end
end end

View File

@ -7,34 +7,8 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionControllerTest do
alias Pleroma.Config alias Pleroma.Config
import Pleroma.Factory
import Tesla.Mock
setup do: oauth_access(["read"]) setup do: oauth_access(["read"])
setup %{user: user} do
other_user = insert(:user)
host = Config.get([Pleroma.Web.Endpoint, :url, :host])
url500 = "http://test500?#{host}&#{user.nickname}"
url200 = "http://test200?#{host}&#{user.nickname}"
mock(fn
%{method: :get, url: ^url500} ->
%Tesla.Env{status: 500, body: "bad request"}
%{method: :get, url: ^url200} ->
%Tesla.Env{
status: 200,
body:
~s([{"acct":"yj455","avatar":"https://social.heldscal.la/avatar/201.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/201.jpeg"}, {"acct":"#{
other_user.ap_id
}","avatar":"https://social.heldscal.la/avatar/202.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/202.jpeg"}])
}
end)
[other_user: other_user]
end
test "returns empty result", %{conn: conn} do test "returns empty result", %{conn: conn} do
res = res =
conn conn

View File

@ -203,7 +203,7 @@ test "PATCH /api/v1/pleroma/conversations/:id" do
test "POST /api/v1/pleroma/conversations/read" do test "POST /api/v1/pleroma/conversations/read" do
user = insert(:user) user = insert(:user)
%{user: other_user, conn: conn} = oauth_access(["write:notifications"]) %{user: other_user, conn: conn} = oauth_access(["write:conversations"])
{:ok, _activity} = {:ok, _activity} =
CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"}) CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"})