Merge branch 'authenticated-api-oauth-check-enforcement' into 'develop'
authorrinpatch <rinpatch@sdf.org>
Thu, 16 Apr 2020 21:58:57 +0000 (21:58 +0000)
committerrinpatch <rinpatch@sdf.org>
Thu, 30 Apr 2020 21:58:40 +0000 (00:58 +0300)
Enforcement of OAuth scopes check for authenticated API endpoints

See merge request pleroma/pleroma!2349

17 files changed:
lib/pleroma/plugs/auth_expected_plug.ex [new file with mode: 0644]
lib/pleroma/plugs/oauth_scopes_plug.ex
lib/pleroma/plugs/plug_helper.ex [new file with mode: 0644]
lib/pleroma/tests/oauth_test_controller.ex [new file with mode: 0644]
lib/pleroma/web/masto_fe_controller.ex
lib/pleroma/web/mastodon_api/controllers/account_controller.ex
lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex
lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex
lib/pleroma/web/oauth/oauth_controller.ex
lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex
lib/pleroma/web/router.ex
lib/pleroma/web/twitter_api/twitter_api_controller.ex
lib/pleroma/web/web.ex
test/plugs/oauth_scopes_plug_test.exs
test/web/auth/oauth_test_controller_test.exs [new file with mode: 0644]
test/web/mastodon_api/controllers/suggestion_controller_test.exs
test/web/pleroma_api/controllers/pleroma_api_controller_test.exs

diff --git a/lib/pleroma/plugs/auth_expected_plug.ex b/lib/pleroma/plugs/auth_expected_plug.ex
new file mode 100644 (file)
index 0000000..f79597d
--- /dev/null
@@ -0,0 +1,17 @@
+# 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
+
+  def auth_expected?(conn) do
+    conn.private[:auth_expected]
+  end
+end
index 38df074adfdd3cc607c476fa1b3f8ec238c9a4d9..66f48c28c600f8fda50dea5c326edf47027125cc 100644 (file)
@@ -8,12 +8,15 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do
 
   alias Pleroma.Config
   alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
+  alias Pleroma.Plugs.PlugHelper
+
+  use Pleroma.Web, :plug
 
   @behaviour Plug
 
   def init(%{scopes: _} = options), do: options
 
-  def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do
+  def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do
     op = options[:op] || :|
     token = assigns[:token]
 
diff --git a/lib/pleroma/plugs/plug_helper.ex b/lib/pleroma/plugs/plug_helper.ex
new file mode 100644 (file)
index 0000000..4f83e94
--- /dev/null
@@ -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
diff --git a/lib/pleroma/tests/oauth_test_controller.ex b/lib/pleroma/tests/oauth_test_controller.ex
new file mode 100644 (file)
index 0000000..58d517f
--- /dev/null
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+# A test controller reachable only in :test env.
+# Serves to test OAuth scopes check skipping / enforcement.
+defmodule Pleroma.Tests.OAuthTestController do
+  @moduledoc false
+
+  use Pleroma.Web, :controller
+
+  alias Pleroma.Plugs.OAuthScopesPlug
+
+  plug(:skip_plug, OAuthScopesPlug when action == :skipped_oauth)
+
+  plug(OAuthScopesPlug, %{scopes: ["read"]} when action != :missed_oauth)
+
+  def skipped_oauth(conn, _params) do
+    noop(conn)
+  end
+
+  def performed_oauth(conn, _params) do
+    noop(conn)
+  end
+
+  def missed_oauth(conn, _params) do
+    noop(conn)
+  end
+
+  defp noop(conn), do: json(conn, %{})
+end
index 43649ad2611dacc509c6f571c7a89cde9240f6e9..557cde328f4cdd47d646d1b2f21769d34a66e8a4 100644 (file)
@@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastoFEController do
     when action == :index
   )
 
-  plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :index)
+  plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [:index, :manifest])
 
   @doc "GET /web/*path"
   def index(%{assigns: %{user: user, token: token}} = conn, _params)
index 73853c1e44fdc6168273e3cf318eaecd0ebcc5a4..229d4be28af7f38114942dda6d94cfbab9ae996d 100644 (file)
@@ -15,10 +15,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
   alias Pleroma.Web.CommonAPI
   alias Pleroma.Web.MastodonAPI.ListView
   alias Pleroma.Web.MastodonAPI.MastodonAPI
+  alias Pleroma.Web.MastodonAPI.MastodonAPIController
   alias Pleroma.Web.MastodonAPI.StatusView
   alias Pleroma.Web.OAuth.Token
   alias Pleroma.Web.TwitterAPI.TwitterAPI
 
+  plug(:skip_plug, OAuthScopesPlug when action == :identity_proofs)
+
   plug(
     OAuthScopesPlug,
     %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
@@ -366,6 +369,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
   end
 
   @doc "GET /api/v1/endorsements"
-  def endorsements(conn, params),
-    do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
+  def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, params)
+
+  @doc "GET /api/v1/identity_proofs"
+  def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)
 end
index 14075307de35b0f154a9ec1ea723823f3353e241..ac8c18f24272892d387406aa30d3d8f24cd87a3d 100644 (file)
@@ -3,21 +3,31 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 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
 
   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)
 
-  # Stubs for unimplemented mastodon api
-  #
   def empty_array(conn, _) do
-    Logger.debug("Unimplemented, returning an empty array")
+    Logger.debug("Unimplemented, returning an empty array (list)")
     json(conn, [])
   end
 
   def empty_object(conn, _) do
-    Logger.debug("Unimplemented, returning an empty object")
+    Logger.debug("Unimplemented, returning an empty object (map)")
     json(conn, %{})
   end
 end
index 0cdc7bd8d9e1316b66ccbdfe3925be03dcc879ad..c93a439696223e619263c03a94260988f4fd36c6 100644 (file)
@@ -5,10 +5,13 @@
 defmodule Pleroma.Web.MastodonAPI.SuggestionController do
   use Pleroma.Web, :controller
 
+  alias Pleroma.Plugs.OAuthScopesPlug
+
   require Logger
 
+  plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :index)
+
   @doc "GET /api/v1/suggestions"
-  def index(conn, _) do
-    json(conn, [])
-  end
+  def index(conn, params),
+    do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
 end
index 46688db7e8358cf85e4a78765812addffab8f76a..0121cd661001211b45f509f0bce2344f6f9f0800 100644 (file)
@@ -27,6 +27,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   plug(:fetch_flash)
   plug(RateLimiter, [name: :authentication] when action == :create_authorization)
 
+  plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug)
+
   action_fallback(Pleroma.Web.OAuth.FallbackController)
 
   @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob"
index 41677d04db9cd2c3fd4cf2c49ee8645455a2a987..f0867c2c1e893b7b88805481bc03174732c4f198 100644 (file)
@@ -34,7 +34,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
 
   plug(
     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)
index cb590acfb4ec4dc76bdbfc50133d5ed93803ca3b..bc2cf8b443145a9381c36a1770d24e54032305d2 100644 (file)
@@ -34,6 +34,7 @@ defmodule Pleroma.Web.Router do
   pipeline :authenticated_api do
     plug(:accepts, ["json"])
     plug(:fetch_session)
+    plug(Pleroma.Plugs.AuthExpectedPlug)
     plug(Pleroma.Plugs.OAuthPlug)
     plug(Pleroma.Plugs.BasicAuthDecoderPlug)
     plug(Pleroma.Plugs.UserFetcherPlug)
@@ -334,7 +335,7 @@ defmodule Pleroma.Web.Router do
     get("/accounts/relationships", AccountController, :relationships)
 
     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("/blocks", AccountController, :blocks)
@@ -657,6 +658,17 @@ defmodule Pleroma.Web.Router do
     end
   end
 
+  # Test-only routes needed to test action dispatching and plug chain execution
+  if Pleroma.Config.get(:env) == :test do
+    scope "/test/authenticated_api", Pleroma.Tests do
+      pipe_through(:authenticated_api)
+
+      for action <- [:skipped_oauth, :performed_oauth, :missed_oauth] do
+        get("/#{action}", OAuthTestController, action)
+      end
+    end
+  end
+
   scope "/", Pleroma.Web.MongooseIM do
     get("/user_exists", MongooseIMController, :user_exists)
     get("/check_password", MongooseIMController, :check_password)
index 0229aea975bba9bd2ca7adb23796bc00c4f57d2f..31adc28174bf1c7af9855c82f2907290d4f681c0 100644 (file)
@@ -15,6 +15,8 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
 
   plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read)
 
+  plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token])
+
   plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
 
   action_fallback(:errors)
index cf3ac1287b364bf9552cc970bf47da6eead76b7d..ae7c94640f83065a91f3cc3e7a68d9540c879845 100644 (file)
@@ -29,11 +29,40 @@ defmodule Pleroma.Web do
       import Pleroma.Web.Router.Helpers
       import Pleroma.Web.TranslationHelpers
 
+      alias Pleroma.Plugs.PlugHelper
+
       plug(:set_put_layout)
 
       defp set_put_layout(conn, _) do
         put_layout(conn, Pleroma.Config.get(:app_layout, "app.html"))
       end
+
+      # Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain
+      defp skip_plug(conn, plug_module) do
+        try do
+          plug_module.ensure_skippable()
+        rescue
+          UndefinedFunctionError ->
+            raise "#{plug_module} is not skippable. Append `use Pleroma.Web, :plug` to its code."
+        end
+
+        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 Pleroma.Plugs.AuthExpectedPlug.auth_expected?(conn) &&
+             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
 
@@ -96,6 +125,26 @@ defmodule Pleroma.Web do
     end
   end
 
+  def plug do
+    quote do
+      alias Pleroma.Plugs.PlugHelper
+
+      def ensure_skippable, do: :noop
+
+      @impl Plug
+      @doc "If marked as skipped, returns `conn`, and calls `perform/2` otherwise."
+      def call(%Plug.Conn{} = conn, options) do
+        if PlugHelper.plug_skipped?(conn, __MODULE__) do
+          conn
+        else
+          conn
+          |> PlugHelper.append_to_called_plugs(__MODULE__)
+          |> perform(options)
+        end
+      end
+    end
+  end
+
   @doc """
   When used, dispatch to the appropriate controller/view/etc.
   """
index 1b3aa85b6e3eee68c72d1823b80257cf66101db5..85105f968176d431fb4aaeab7a9c8c5ac2db44c7 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do
 
   alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
   alias Pleroma.Plugs.OAuthScopesPlug
+  alias Pleroma.Plugs.PlugHelper
   alias Pleroma.Repo
 
   import Mock
@@ -16,6 +17,18 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do
     :ok
   end
 
+  test "is not performed if marked as skipped", %{conn: conn} do
+    with_mock OAuthScopesPlug, [:passthrough], perform: &passthrough([&1, &2]) do
+      conn =
+        conn
+        |> PlugHelper.append_to_skipped_plugs(OAuthScopesPlug)
+        |> OAuthScopesPlug.call(%{scopes: ["random_scope"]})
+
+      refute called(OAuthScopesPlug.perform(:_, :_))
+      refute conn.halted
+    end
+  end
+
   test "if `token.scopes` fulfills specified 'any of' conditions, " <>
          "proceeds with no op",
        %{conn: conn} do
diff --git a/test/web/auth/oauth_test_controller_test.exs b/test/web/auth/oauth_test_controller_test.exs
new file mode 100644 (file)
index 0000000..a2f6009
--- /dev/null
@@ -0,0 +1,49 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Tests.OAuthTestControllerTest do
+  use Pleroma.Web.ConnCase
+
+  import Pleroma.Factory
+
+  setup %{conn: conn} do
+    user = insert(:user)
+    conn = assign(conn, :user, user)
+    %{conn: conn, user: user}
+  end
+
+  test "missed_oauth", %{conn: conn} do
+    res =
+      conn
+      |> get("/test/authenticated_api/missed_oauth")
+      |> json_response(403)
+
+    assert res ==
+             %{
+               "error" =>
+                 "Security violation: OAuth scopes check was neither handled nor explicitly skipped."
+             }
+  end
+
+  test "skipped_oauth", %{conn: conn} do
+    conn
+    |> assign(:token, nil)
+    |> get("/test/authenticated_api/skipped_oauth")
+    |> json_response(200)
+  end
+
+  test "performed_oauth", %{user: user} do
+    %{conn: good_token_conn} = oauth_access(["read"], user: user)
+
+    good_token_conn
+    |> get("/test/authenticated_api/performed_oauth")
+    |> json_response(200)
+
+    %{conn: bad_token_conn} = oauth_access(["follow"], user: user)
+
+    bad_token_conn
+    |> get("/test/authenticated_api/performed_oauth")
+    |> json_response(403)
+  end
+end
index c697a39f8bf3b1fa6387204f286367b7e07e5831..8d0e70db86750cf99165a44c903afd25e6331b8c 100644 (file)
@@ -7,34 +7,8 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionControllerTest do
 
   alias Pleroma.Config
 
-  import Pleroma.Factory
-  import Tesla.Mock
-
   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
     res =
       conn
index 32250f06f6332380f785ebe10f038f78c7fe1373..8f0cbe9b25e669378541597a442aca4ed1d0e927 100644 (file)
@@ -203,7 +203,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
 
   test "POST /api/v1/pleroma/conversations/read" do
     user = insert(:user)
-    %{user: other_user, conn: conn} = oauth_access(["write:notifications"])
+    %{user: other_user, conn: conn} = oauth_access(["write:conversations"])
 
     {:ok, _activity} =
       CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"})