Prevent key-actor mapping poisoning and key take overs
Previously there were mainly two attack vectors:
 - for raw keys the owner <-> key mapping wasn't verified at all
 - keys were retrieved with refetching allowed
   and only the top-level ID was sanitised while
   usually keys are but a subobject
This reintroduces public key checks in the user actor,
previously removed in 9728e2f8f7
but now adapted to account for the new mapping mechanism.
			
			
This commit is contained in:
		
							parent
							
								
									366065c0f6
								
							
						
					
					
						commit
						70fe99d196
					
				
					 8 changed files with 174 additions and 13 deletions
				
			
		|  | @ -157,4 +157,16 @@ def same_origin(id1, id2) do | ||||||
| 
 | 
 | ||||||
|     compare_uris(uri1, uri2) |     compare_uris(uri1, uri2) | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   @doc """ | ||||||
|  |   Checks whether a key_id - owner_id pair are acceptable. | ||||||
|  | 
 | ||||||
|  |   While in theory keys and actors on different domain could be verified | ||||||
|  |   by fetching both and checking the links on both ends (if different at all), | ||||||
|  |   this requires extra fetches and there are no known implementations with split | ||||||
|  |   actor and key domains, thus atm this simply requires same domain. | ||||||
|  |   """ | ||||||
|  |   def contain_key_user(key_id, user_id) do | ||||||
|  |     same_origin(key_id, user_id) | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -275,6 +275,42 @@ def fetch_and_contain_remote_object_from_id(id, is_ap_id) when is_binary(id) do | ||||||
|   def fetch_and_contain_remote_object_from_id(_id, _is_ap_id), |   def fetch_and_contain_remote_object_from_id(_id, _is_ap_id), | ||||||
|     do: {:error, :invalid_id} |     do: {:error, :invalid_id} | ||||||
| 
 | 
 | ||||||
|  |   def fetch_and_contain_remote_key(id) do | ||||||
|  |     Logger.debug("Fetching remote actor key #{id}") | ||||||
|  | 
 | ||||||
|  |     fetch_and_contain_remote_ap_doc( | ||||||
|  |       id, | ||||||
|  |       # key IDs are alwas AP IDs which should resolve directly and exactly | ||||||
|  |       true, | ||||||
|  |       fn | ||||||
|  |         final_uri, %{"id" => user_id, "publicKey" => %{"id" => key_id}} -> | ||||||
|  |           # For non-fragment keys as used e.g. by GTS, the "id" won't match the fetch URI, | ||||||
|  |           # but the key ID will. Thus do NOT strict check the top-lelve id, but byte-exact | ||||||
|  |           # check the key ID (since for later lookups we need byte-exact matches). | ||||||
|  |           # This relies on fetching already enforcing a domain match for toplevel id and host | ||||||
|  |           with :ok <- Containment.contain_key_user(key_id, user_id), | ||||||
|  |                true <- key_id == final_uri do | ||||||
|  |             {:ok, key_id} | ||||||
|  |           else | ||||||
|  |             _ -> {:error, key_id} | ||||||
|  |           end | ||||||
|  | 
 | ||||||
|  |         final_uri, | ||||||
|  |         %{"type" => "CryptographicKey", "id" => key_id, "owner" => user_id, "publicKeyPem" => _} -> | ||||||
|  |           # XXX: refactor into separate function isntead of duplicating | ||||||
|  |           with :ok <- Containment.contain_key_user(key_id, user_id), | ||||||
|  |                true <- key_id == final_uri do | ||||||
|  |             {:ok, key_id} | ||||||
|  |           else | ||||||
|  |             _ -> {:error, nil} | ||||||
|  |           end | ||||||
|  | 
 | ||||||
|  |         _, _ -> | ||||||
|  |           {:error, nil} | ||||||
|  |       end | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   # Fetches an AP document and performing variable security checks on it. |   # Fetches an AP document and performing variable security checks on it. | ||||||
|   # |   # | ||||||
|   # Note that the received documents "id" matching the final host domain |   # Note that the received documents "id" matching the final host domain | ||||||
|  |  | ||||||
|  | @ -196,7 +196,7 @@ def fetch_remote_key(key_id) do | ||||||
|     Logger.debug("Fetching remote key: #{key_id}") |     Logger.debug("Fetching remote key: #{key_id}") | ||||||
| 
 | 
 | ||||||
|     with {:ok, _body} = resp <- |     with {:ok, _body} = resp <- | ||||||
|            Pleroma.Object.Fetcher.fetch_and_contain_remote_object_from_id(key_id), |            Pleroma.Object.Fetcher.fetch_and_contain_remote_key(key_id), | ||||||
|          {:ok, ap_id, public_key_pem} <- handle_signature_response(resp) do |          {:ok, ap_id, public_key_pem} <- handle_signature_response(resp) do | ||||||
|       Logger.debug("Fetched remote key: #{ap_id}") |       Logger.debug("Fetched remote key: #{ap_id}") | ||||||
|       # fetch the user |       # fetch the user | ||||||
|  |  | ||||||
|  | @ -22,7 +22,8 @@ def validate(object, meta) | ||||||
| 
 | 
 | ||||||
|   def validate(%{"type" => type, "id" => _id} = data, meta) |   def validate(%{"type" => type, "id" => _id} = data, meta) | ||||||
|       when type in Pleroma.Constants.actor_types() do |       when type in Pleroma.Constants.actor_types() do | ||||||
|     with :ok <- validate_inbox(data), |     with :ok <- validate_pubkey(data), | ||||||
|  |          :ok <- validate_inbox(data), | ||||||
|          :ok <- contain_collection_origin(data) do |          :ok <- contain_collection_origin(data) do | ||||||
|       {:ok, data, meta} |       {:ok, data, meta} | ||||||
|     else |     else | ||||||
|  | @ -33,6 +34,21 @@ def validate(%{"type" => type, "id" => _id} = data, meta) | ||||||
| 
 | 
 | ||||||
|   def validate(_, _), do: {:error, "Not a user object"} |   def validate(_, _), do: {:error, "Not a user object"} | ||||||
| 
 | 
 | ||||||
|  |   defp validate_pubkey(%{"id" => user_id, "publicKey" => %{"id" => pk_id, "publicKeyPem" => _key}}) do | ||||||
|  |     with {_, true} <- {:user, is_binary(user_id)}, | ||||||
|  |          {_, true} <- {:key, is_binary(pk_id)}, | ||||||
|  |          :ok <- Containment.contain_key_user(pk_id, user_id) do | ||||||
|  |       :ok | ||||||
|  |     else | ||||||
|  |       {:user, _} -> {:error, "Invalid user id: #{inspect(user_id)}"} | ||||||
|  |       {:key, _} -> {:error, "Invalid key id: #{inspect(pk_id)}"} | ||||||
|  |       :error -> {:error, "Problematic actor-key pairing: #{user_id} - #{pk_id}"} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   # pubkey is optional atm | ||||||
|  |   defp validate_pubkey(_data), do: :ok | ||||||
|  | 
 | ||||||
|   defp validate_inbox(%{"id" => id, "inbox" => inbox}) do |   defp validate_inbox(%{"id" => id, "inbox" => inbox}) do | ||||||
|     case Containment.same_origin(id, inbox) do |     case Containment.same_origin(id, inbox) do | ||||||
|       :ok -> :ok |       :ok -> :ok | ||||||
|  |  | ||||||
							
								
								
									
										54
									
								
								test/fixtures/tesla_mock/evil@remote.example_with_keyid_from_admin@mastdon.example.org.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								test/fixtures/tesla_mock/evil@remote.example_with_keyid_from_admin@mastdon.example.org.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | ||||||
|  | { | ||||||
|  |   "@context": [ | ||||||
|  |     "https://www.w3.org/ns/activitystreams", | ||||||
|  |     "https://w3id.org/security/v1", | ||||||
|  |     { | ||||||
|  |       "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", | ||||||
|  |       "sensitive": "as:sensitive", | ||||||
|  |       "movedTo": "as:movedTo", | ||||||
|  |       "Hashtag": "as:Hashtag", | ||||||
|  |       "ostatus": "http://ostatus.org#", | ||||||
|  |       "atomUri": "ostatus:atomUri", | ||||||
|  |       "inReplyToAtomUri": "ostatus:inReplyToAtomUri", | ||||||
|  |       "conversation": "ostatus:conversation", | ||||||
|  |       "toot": "http://joinmastodon.org/ns#", | ||||||
|  |       "Emoji": "toot:Emoji", | ||||||
|  |       "alsoKnownAs": { | ||||||
|  |         "@id": "as:alsoKnownAs", | ||||||
|  |         "@type": "@id" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "id": "http://remote.example/users/with_key_id_of_admin-mastodon.example.org", | ||||||
|  |   "type": "Person", | ||||||
|  |   "following": "http://remote.example/users/evil/following", | ||||||
|  |   "followers": "http://remote.example/users/evil/followers", | ||||||
|  |   "inbox": "http://remote.example/users/evil/inbox", | ||||||
|  |   "outbox": "http://remote.example/users/evil/outbox", | ||||||
|  |   "preferredUsername": "evil", | ||||||
|  |   "name": null, | ||||||
|  |   "discoverable": "true", | ||||||
|  |   "summary": "hii", | ||||||
|  |   "url": "http://remote.example/@evil", | ||||||
|  |   "manuallyApprovesFollowers": false, | ||||||
|  |   "publicKey": { | ||||||
|  |     "id": "http://mastodon.example.org/users/admin#main-key", | ||||||
|  |     "owner": "http://remote.example/users/evil", | ||||||
|  |     "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtc4Tir+3ADhSNF6VKrtW\nOU32T01w7V0yshmQei38YyiVwVvFu8XOP6ACchkdxbJ+C9mZud8qWaRJKVbFTMUG\nNX4+6Q+FobyuKrwN7CEwhDALZtaN2IPbaPd6uG1B7QhWorrY+yFa8f2TBM3BxnUy\nI4T+bMIZIEYG7KtljCBoQXuTQmGtuffO0UwJksidg2ffCF5Q+K//JfQagJ3UzrR+\nZXbKMJdAw4bCVJYs4Z5EhHYBwQWiXCyMGTd7BGlmMkY6Av7ZqHKC/owp3/0EWDNz\nNqF09Wcpr3y3e8nA10X40MJqp/wR+1xtxp+YGbq/Cj5hZGBG7etFOmIpVBrDOhry\nBwIDAQAB\n-----END PUBLIC KEY-----\n" | ||||||
|  |   }, | ||||||
|  |   "attachment": [], | ||||||
|  |   "endpoints": { | ||||||
|  |     "sharedInbox": "http://remote.example/inbox" | ||||||
|  |   }, | ||||||
|  |   "icon": { | ||||||
|  |     "type": "Image", | ||||||
|  |     "mediaType": "image/jpeg", | ||||||
|  |     "url": "https://cdn.niu.moe/accounts/avatars/000/033/323/original/fd7f8ae0b3ffedc9.jpeg" | ||||||
|  |   }, | ||||||
|  |   "image": { | ||||||
|  |     "type": "Image", | ||||||
|  |     "mediaType": "image/png", | ||||||
|  |     "url": "https://cdn.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png" | ||||||
|  |   }, | ||||||
|  |   "alsoKnownAs": [] | ||||||
|  | } | ||||||
|  | @ -10,6 +10,7 @@ defmodule Pleroma.UserTest do | ||||||
|   alias Pleroma.Repo |   alias Pleroma.Repo | ||||||
|   alias Pleroma.Tests.ObanHelpers |   alias Pleroma.Tests.ObanHelpers | ||||||
|   alias Pleroma.User |   alias Pleroma.User | ||||||
|  |   alias Pleroma.User.SigningKey | ||||||
|   alias Pleroma.Web.ActivityPub.ActivityPub |   alias Pleroma.Web.ActivityPub.ActivityPub | ||||||
|   alias Pleroma.Web.CommonAPI |   alias Pleroma.Web.CommonAPI | ||||||
| 
 | 
 | ||||||
|  | @ -908,6 +909,35 @@ test "it doesn't fail on invalid alsoKnownAs entries" do | ||||||
|       assert {:ok, %User{also_known_as: []}} = |       assert {:ok, %User{also_known_as: []}} = | ||||||
|                User.get_or_fetch_by_ap_id("https://mbp.example.com/") |                User.get_or_fetch_by_ap_id("https://mbp.example.com/") | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     test "doesn't allow key_id poisoning" do | ||||||
|  |       {:error, {:validate, {:error, "Problematic actor-key pairing:" <> _}}} = | ||||||
|  |         User.fetch_by_ap_id( | ||||||
|  |           "http://remote.example/users/with_key_id_of_admin-mastodon.example.org" | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |       used_key_id = "http://mastodon.example.org/users/admin#main-key" | ||||||
|  |       refute Repo.get_by(SigningKey, key_id: used_key_id) | ||||||
|  | 
 | ||||||
|  |       {:ok, user} = User.fetch_by_ap_id("http://mastodon.example.org/users/admin") | ||||||
|  |       user = SigningKey.load_key(user) | ||||||
|  | 
 | ||||||
|  |       # ensure we checked for the right key before | ||||||
|  |       assert user.signing_key.key_id == used_key_id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     test "doesn't allow key_id takeovers" do | ||||||
|  |       {:ok, user} = User.fetch_by_ap_id("http://mastodon.example.org/users/admin") | ||||||
|  |       user = SigningKey.load_key(user) | ||||||
|  | 
 | ||||||
|  |       {:error, {:validate, {:error, "Problematic actor-key pairing:" <> _}}} = | ||||||
|  |         User.fetch_by_ap_id( | ||||||
|  |           "http://remote.example/users/with_key_id_of_admin-mastodon.example.org" | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |       refreshed_sk = Repo.get_by(SigningKey, key_id: user.signing_key.key_id) | ||||||
|  |       assert refreshed_sk.user_id == user.id | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   test "returns an ap_id for a user" do |   test "returns an ap_id for a user" do | ||||||
|  |  | ||||||
|  | @ -561,7 +561,7 @@ test "it inserts an incoming activity into the database", %{conn: conn} do | ||||||
|         |> assign(:valid_signature, true) |         |> assign(:valid_signature, true) | ||||||
|         |> put_req_header( |         |> put_req_header( | ||||||
|           "signature", |           "signature", | ||||||
|           "keyId=\"http://mastodon.example.org/users/admin/main-key\"" |           "keyId=\"http://mastodon.example.org/users/admin#main-key\"" | ||||||
|         ) |         ) | ||||||
|         |> put_req_header("content-type", "application/activity+json") |         |> put_req_header("content-type", "application/activity+json") | ||||||
|         |> post("/inbox", data) |         |> post("/inbox", data) | ||||||
|  | @ -679,7 +679,7 @@ test "accepts Add/Remove activities", %{conn: conn} do | ||||||
|         |> String.replace("{{nickname}}", "lain") |         |> String.replace("{{nickname}}", "lain") | ||||||
| 
 | 
 | ||||||
|       actor = "https://example.com/users/lain" |       actor = "https://example.com/users/lain" | ||||||
|       key_id = "#{actor}/main-key" |       key_id = "#{actor}#main-key" | ||||||
| 
 | 
 | ||||||
|       insert(:user, |       insert(:user, | ||||||
|         ap_id: actor, |         ap_id: actor, | ||||||
|  | @ -741,7 +741,7 @@ test "accepts Add/Remove activities", %{conn: conn} do | ||||||
|       assert "ok" == |       assert "ok" == | ||||||
|                conn |                conn | ||||||
|                |> assign(:valid_signature, true) |                |> assign(:valid_signature, true) | ||||||
|                |> put_req_header("signature", "keyId=\"#{actor}/main-key\"") |                |> put_req_header("signature", "keyId=\"#{actor}#main-key\"") | ||||||
|                |> put_req_header("content-type", "application/activity+json") |                |> put_req_header("content-type", "application/activity+json") | ||||||
|                |> post("/inbox", data) |                |> post("/inbox", data) | ||||||
|                |> json_response(200) |                |> json_response(200) | ||||||
|  | @ -764,7 +764,7 @@ test "accepts Add/Remove activities", %{conn: conn} do | ||||||
|       assert "ok" == |       assert "ok" == | ||||||
|                conn |                conn | ||||||
|                |> assign(:valid_signature, true) |                |> assign(:valid_signature, true) | ||||||
|                |> put_req_header("signature", "keyId=\"#{actor}/main-key\"") |                |> put_req_header("signature", "keyId=\"#{actor}#main-key\"") | ||||||
|                |> put_req_header("content-type", "application/activity+json") |                |> put_req_header("content-type", "application/activity+json") | ||||||
|                |> post("/inbox", data) |                |> post("/inbox", data) | ||||||
|                |> json_response(200) |                |> json_response(200) | ||||||
|  | @ -790,7 +790,7 @@ test "mastodon pin/unpin", %{conn: conn} do | ||||||
|         |> String.replace("{{nickname}}", "lain") |         |> String.replace("{{nickname}}", "lain") | ||||||
| 
 | 
 | ||||||
|       actor = "https://example.com/users/lain" |       actor = "https://example.com/users/lain" | ||||||
|       key_id = "#{actor}/main-key" |       key_id = "#{actor}#main-key" | ||||||
| 
 | 
 | ||||||
|       sender = |       sender = | ||||||
|         insert(:user, |         insert(:user, | ||||||
|  | @ -883,7 +883,7 @@ test "mastodon pin/unpin", %{conn: conn} do | ||||||
|       assert "ok" == |       assert "ok" == | ||||||
|                conn |                conn | ||||||
|                |> assign(:valid_signature, true) |                |> assign(:valid_signature, true) | ||||||
|                |> put_req_header("signature", "keyId=\"#{actor}/main-key\"") |                |> put_req_header("signature", "keyId=\"#{actor}#main-key\"") | ||||||
|                |> put_req_header("content-type", "application/activity+json") |                |> put_req_header("content-type", "application/activity+json") | ||||||
|                |> post("/inbox", data) |                |> post("/inbox", data) | ||||||
|                |> json_response(200) |                |> json_response(200) | ||||||
|  | @ -915,7 +915,7 @@ test "it inserts an incoming activity into the database", %{conn: conn, data: da | ||||||
|       conn = |       conn = | ||||||
|         conn |         conn | ||||||
|         |> assign(:valid_signature, true) |         |> assign(:valid_signature, true) | ||||||
|         |> put_req_header("signature", "keyId=\"#{data["actor"]}/main-key\"") |         |> put_req_header("signature", "keyId=\"#{data["actor"]}#main-key\"") | ||||||
|         |> put_req_header("content-type", "application/activity+json") |         |> put_req_header("content-type", "application/activity+json") | ||||||
|         |> post("/users/#{user.nickname}/inbox", data) |         |> post("/users/#{user.nickname}/inbox", data) | ||||||
| 
 | 
 | ||||||
|  | @ -939,7 +939,7 @@ test "it accepts messages with to as string instead of array", %{conn: conn, dat | ||||||
|       conn = |       conn = | ||||||
|         conn |         conn | ||||||
|         |> assign(:valid_signature, true) |         |> assign(:valid_signature, true) | ||||||
|         |> put_req_header("signature", "keyId=\"#{data["actor"]}/main-key\"") |         |> put_req_header("signature", "keyId=\"#{data["actor"]}#main-key\"") | ||||||
|         |> put_req_header("content-type", "application/activity+json") |         |> put_req_header("content-type", "application/activity+json") | ||||||
|         |> post("/users/#{user.nickname}/inbox", data) |         |> post("/users/#{user.nickname}/inbox", data) | ||||||
| 
 | 
 | ||||||
|  | @ -961,7 +961,7 @@ test "it accepts messages with cc as string instead of array", %{conn: conn, dat | ||||||
|       conn = |       conn = | ||||||
|         conn |         conn | ||||||
|         |> assign(:valid_signature, true) |         |> assign(:valid_signature, true) | ||||||
|         |> put_req_header("signature", "keyId=\"#{data["actor"]}/main-key\"") |         |> put_req_header("signature", "keyId=\"#{data["actor"]}#main-key\"") | ||||||
|         |> put_req_header("content-type", "application/activity+json") |         |> put_req_header("content-type", "application/activity+json") | ||||||
|         |> post("/users/#{user.nickname}/inbox", data) |         |> post("/users/#{user.nickname}/inbox", data) | ||||||
| 
 | 
 | ||||||
|  | @ -988,7 +988,7 @@ test "it accepts messages with bcc as string instead of array", %{conn: conn, da | ||||||
|       conn = |       conn = | ||||||
|         conn |         conn | ||||||
|         |> assign(:valid_signature, true) |         |> assign(:valid_signature, true) | ||||||
|         |> put_req_header("signature", "keyId=\"#{data["actor"]}/main-key\"") |         |> put_req_header("signature", "keyId=\"#{data["actor"]}#main-key\"") | ||||||
|         |> put_req_header("content-type", "application/activity+json") |         |> put_req_header("content-type", "application/activity+json") | ||||||
|         |> post("/users/#{user.nickname}/inbox", data) |         |> post("/users/#{user.nickname}/inbox", data) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -424,7 +424,7 @@ def get("http://mastodon.example.org/users/admin", _, _, _) do | ||||||
|      }} |      }} | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def get("http://mastodon.example.org/users/admin/main-key", _, _, _) do |   def get("http://mastodon.example.org/users/admin#main-key", _, _, _) do | ||||||
|     {:ok, |     {:ok, | ||||||
|      %Tesla.Env{ |      %Tesla.Env{ | ||||||
|        status: 200, |        status: 200, | ||||||
|  | @ -433,6 +433,19 @@ def get("http://mastodon.example.org/users/admin/main-key", _, _, _) do | ||||||
|      }} |      }} | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def get("http://remote.example/users/with_key_id_of_admin-mastodon.example.org" = url, _, _, _) do | ||||||
|  |     {:ok, | ||||||
|  |      %Tesla.Env{ | ||||||
|  |        status: 200, | ||||||
|  |        url: url, | ||||||
|  |        body: | ||||||
|  |          File.read!( | ||||||
|  |            "test/fixtures/tesla_mock/evil@remote.example_with_keyid_from_admin@mastdon.example.org.json" | ||||||
|  |          ), | ||||||
|  |        headers: activitypub_object_headers() | ||||||
|  |      }} | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def get( |   def get( | ||||||
|         "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies?min_id=99512778738411824&page=true", |         "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies?min_id=99512778738411824&page=true", | ||||||
|         _, |         _, | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue
	
	 Oneric
						Oneric