ForForkMerge #2

Merged
sliver merged 185 commits from ForForkMerge into stable 2024-03-31 06:59:36 +00:00
9 changed files with 174 additions and 12 deletions
Showing only changes of commit 8684964c5d - Show all commits

View file

@ -11,6 +11,9 @@ defmodule Pleroma.Object.Containment do
Object containment is an important step in validating remote objects to prevent Object containment is an important step in validating remote objects to prevent
spoofing, therefore removal of object containment functions is NOT recommended. spoofing, therefore removal of object containment functions is NOT recommended.
""" """
alias Pleroma.Web.ActivityPub.Transmogrifier
def get_actor(%{"actor" => actor}) when is_binary(actor) do def get_actor(%{"actor" => actor}) when is_binary(actor) do
actor actor
end end
@ -47,6 +50,18 @@ def get_object(_) do
defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok
defp compare_uris(_id_uri, _other_uri), do: :error defp compare_uris(_id_uri, _other_uri), do: :error
defp compare_uris_exact(uri, uri), do: :ok
defp compare_uris_exact(%URI{} = id, %URI{} = other),
do: compare_uris_exact(URI.to_string(id), URI.to_string(other))
defp compare_uris_exact(id_uri, other_uri)
when is_binary(id_uri) and is_binary(other_uri) do
norm_id = String.replace_suffix(id_uri, "/", "")
norm_other = String.replace_suffix(other_uri, "/", "")
if norm_id == norm_other, do: :ok, else: :error
end
@doc """ @doc """
Checks whether an URL to fetch from is from the local server. Checks whether an URL to fetch from is from the local server.
@ -77,6 +92,26 @@ def contain_origin(id, %{"attributedTo" => actor} = params),
def contain_origin(_id, _data), do: :ok def contain_origin(_id, _data), do: :ok
@doc """
Check whether the fetch URL (after redirects) exactly (sans tralining slash) matches either
the canonical ActivityPub id or the objects url field (for display URLs from *key and Mastodon)
Since this is meant to be used for fetches, anonymous or transient objects are not accepted here.
"""
def contain_id_to_fetch(url, %{"id" => id} = data) when is_binary(id) do
with {:id, :error} <- {:id, compare_uris_exact(id, url)},
# "url" can be a "Link" object and this is checked before full normalisation
display_url <- Transmogrifier.fix_url(data)["url"],
true <- display_url != nil do
compare_uris_exact(display_url, url)
else
{:id, :ok} -> :ok
_ -> :error
end
end
def contain_id_to_fetch(_url, _data), do: :error
@doc """ @doc """
Check whether the object id is from the same host as another id Check whether the object id is from the same host as another id
""" """

View file

@ -263,7 +263,7 @@ def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do
{_, :ok} <- {:local_fetch, Containment.contain_local_fetch(id)}, {_, :ok} <- {:local_fetch, Containment.contain_local_fetch(id)},
{:ok, final_id, body} <- get_object(id), {:ok, final_id, body} <- get_object(id),
{:ok, data} <- safe_json_decode(body), {:ok, data} <- safe_json_decode(body),
{_, :ok} <- {:containment, Containment.contain_origin_from_id(final_id, data)}, {_, :ok} <- {:strict_id, Containment.contain_id_to_fetch(final_id, data)},
{_, :ok} <- {:containment, Containment.contain_origin(final_id, data)} do {_, :ok} <- {:containment, Containment.contain_origin(final_id, data)} do
unless Instances.reachable?(final_id) do unless Instances.reachable?(final_id) do
Instances.set_reachable(final_id) Instances.set_reachable(final_id)
@ -271,6 +271,9 @@ def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do
{:ok, data} {:ok, data}
else else
{:strict_id, _} ->
{:error, "Object's ActivityPub id/url does not match final fetch URL"}
{:scheme, _} -> {:scheme, _} ->
{:error, "Unsupported URI scheme"} {:error, "Unsupported URI scheme"}

View file

@ -3,7 +3,7 @@
"attributedTo": "http://mastodon.example.org/users/admin", "attributedTo": "http://mastodon.example.org/users/admin",
"attachment": [], "attachment": [],
"content": "<p>this post was not actually written by Haelwenn</p>", "content": "<p>this post was not actually written by Haelwenn</p>",
"id": "https://info.pleroma.site/activity2.json", "id": "https://info.pleroma.site/activity3.json",
"published": "2018-09-01T22:15:00Z", "published": "2018-09-01T22:15:00Z",
"tag": [], "tag": [],
"to": [ "to": [

View file

@ -11,7 +11,7 @@
"toot": "http://joinmastodon.org/ns#", "toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji" "Emoji": "toot:Emoji"
}], }],
"id": "http://mastodon.example.org/users/admin", "id": "http://mastodon.example.org/users/relay",
"type": "Application", "type": "Application",
"invisible": true, "invisible": true,
"following": "http://mastodon.example.org/users/admin/following", "following": "http://mastodon.example.org/users/admin/following",
@ -24,8 +24,8 @@
"url": "http://mastodon.example.org/@admin", "url": "http://mastodon.example.org/@admin",
"manuallyApprovesFollowers": false, "manuallyApprovesFollowers": false,
"publicKey": { "publicKey": {
"id": "http://mastodon.example.org/users/admin#main-key", "id": "http://mastodon.example.org/users/relay#main-key",
"owner": "http://mastodon.example.org/users/admin", "owner": "http://mastodon.example.org/users/relay",
"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" "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": [{ "attachment": [{

View file

@ -12,11 +12,14 @@ defmodule Akkoma.Collections.FetcherTest do
end end
test "it should extract items from an embedded array in a Collection" do test "it should extract items from an embedded array in a Collection" do
ap_id = "https://example.com/collection/ordered_array"
unordered_collection = unordered_collection =
"test/fixtures/collections/unordered_array.json" "test/fixtures/collections/unordered_array.json"
|> File.read!() |> File.read!()
|> Jason.decode!()
ap_id = "https://example.com/collection/ordered_array" |> Map.put("id", ap_id)
|> Jason.encode!(pretty: true)
Tesla.Mock.mock(fn Tesla.Mock.mock(fn
%{ %{

View file

@ -105,6 +105,56 @@ test "contain_origin_from_id() allows matching IDs" do
) )
end end
test "contain_id_to_fetch() refuses alternate IDs within the same origin domain" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json",
"url" => "http://example.com/@alyssa/status/1234"
}
:error =
Containment.contain_id_to_fetch(
"http://example.com/~alyssa/activities/1234",
data
)
end
test "contain_id_to_fetch() allows matching IDs" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json/"
}
:ok =
Containment.contain_id_to_fetch(
"http://example.com/~alyssa/activities/1234.json/",
data
)
:ok =
Containment.contain_id_to_fetch(
"http://example.com/~alyssa/activities/1234.json",
data
)
end
test "contain_id_to_fetch() allows display URLs" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json",
"url" => "http://example.com/@alyssa/status/1234"
}
:ok =
Containment.contain_id_to_fetch(
"http://example.com/@alyssa/status/1234",
data
)
:ok =
Containment.contain_id_to_fetch(
"http://example.com/@alyssa/status/1234/",
data
)
end
test "users cannot be collided through fake direction spoofing attempts" do test "users cannot be collided through fake direction spoofing attempts" do
_user = _user =
insert(:user, %{ insert(:user, %{

View file

@ -57,6 +57,46 @@ defp spoofed_object_with_ids(
body: spoofed_object_with_ids("https://patch.cx/objects/spoof_content_type") body: spoofed_object_with_ids("https://patch.cx/objects/spoof_content_type")
} }
# Spoof: mismatching ids
# Variant 1: Non-exisitng fake id
%{
method: :get,
url:
"https://patch.cx/media/03ca3c8b4ac3ddd08bf0f84be7885f2f88de0f709112131a22d83650819e36c2.json"
} ->
%Tesla.Env{
status: 200,
url:
"https://patch.cx/media/03ca3c8b4ac3ddd08bf0f84be7885f2f88de0f709112131a22d83650819e36c2.json",
headers: [{"content-type", "application/activity+json"}],
body: spoofed_object_with_ids()
}
%{method: :get, url: "https://patch.cx/objects/spoof"} ->
%Tesla.Env{
status: 404,
url: "https://patch.cx/objects/spoof",
headers: [],
body: "Not found"
}
# Varaint 2: two-stage payload
%{method: :get, url: "https://patch.cx/media/spoof_stage1.json"} ->
%Tesla.Env{
status: 200,
url: "https://patch.cx/media/spoof_stage1.json",
headers: [{"content-type", "application/activity+json"}],
body: spoofed_object_with_ids("https://patch.cx/media/spoof_stage2.json")
}
%{method: :get, url: "https://patch.cx/media/spoof_stage2.json"} ->
%Tesla.Env{
status: 200,
url: "https://patch.cx/media/spoof_stage2.json",
headers: [{"content-type", "application/activity+json"}],
body: spoofed_object_with_ids("https://patch.cx/media/unpredictable.json")
}
# Spoof: cross-domain redirect with original domain id # Spoof: cross-domain redirect with original domain id
%{method: :get, url: "https://patch.cx/objects/spoof_media_redirect1"} -> %{method: :get, url: "https://patch.cx/objects/spoof_media_redirect1"} ->
%Tesla.Env{ %Tesla.Env{
@ -79,7 +119,7 @@ defp spoofed_object_with_ids(
%{method: :get, url: "https://patch.cx/objects/spoof_redirect"} -> %{method: :get, url: "https://patch.cx/objects/spoof_redirect"} ->
%Tesla.Env{ %Tesla.Env{
status: 200, status: 200,
url: "https://patch.cx/objects/spoof", url: "https://patch.cx/objects/spoof_redirect",
headers: [{"content-type", "application/activity+json"}], headers: [{"content-type", "application/activity+json"}],
body: spoofed_object_with_ids("https://patch.cx/objects/spoof_redirect") body: spoofed_object_with_ids("https://patch.cx/objects/spoof_redirect")
} }
@ -110,6 +150,7 @@ defp spoofed_object_with_ids(
%{method: :get, url: "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM"} -> %{method: :get, url: "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM"} ->
%Tesla.Env{ %Tesla.Env{
status: 200, status: 200,
url: "https://social.sakamoto.gq/objects/f20f2497-66d9-4a52-a2e1-1be2a39c32c1",
body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json"), body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json"),
headers: HttpRequestMock.activitypub_object_headers() headers: HttpRequestMock.activitypub_object_headers()
} }
@ -208,6 +249,18 @@ test "it does not fetch a spoofed object with wrong content type" do
) )
end end
test "it does not fetch a spoofed object with id different from URL" do
assert {:error, "Object's ActivityPub id/url does not match final fetch URL"} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://patch.cx/media/03ca3c8b4ac3ddd08bf0f84be7885f2f88de0f709112131a22d83650819e36c2.json"
)
assert {:error, "Object's ActivityPub id/url does not match final fetch URL"} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://patch.cx/media/spoof_stage1.json"
)
end
test "it does not fetch an object via cross-domain redirects (initial id)" do test "it does not fetch an object via cross-domain redirects (initial id)" do
assert {:error, {:cross_domain_redirect, true}} = assert {:error, {:cross_domain_redirect, true}} =
Fetcher.fetch_and_contain_remote_object_from_id( Fetcher.fetch_and_contain_remote_object_from_id(

View file

@ -312,7 +312,7 @@ test "fetches user featured collection" do
end end
test "fetches user featured collection using the first property" do test "fetches user featured collection using the first property" do
featured_url = "https://friendica.example.com/raha/collections/featured" featured_url = "https://friendica.example.com/featured/raha"
first_url = "https://friendica.example.com/featured/raha?page=1" first_url = "https://friendica.example.com/featured/raha?page=1"
featured_data = featured_data =
@ -350,7 +350,7 @@ test "fetches user featured collection using the first property" do
end end
test "fetches user featured when it has string IDs" do test "fetches user featured when it has string IDs" do
featured_url = "https://example.com/alisaie/collections/featured" featured_url = "https://example.com/users/alisaie/collections/featured"
dead_url = "https://example.com/users/alisaie/statuses/108311386746229284" dead_url = "https://example.com/users/alisaie/statuses/108311386746229284"
featured_data = featured_data =
@ -1720,7 +1720,7 @@ test "doesn't crash when follower and following counters are hidden" do
json( json(
%{ %{
"@context" => "https://www.w3.org/ns/activitystreams", "@context" => "https://www.w3.org/ns/activitystreams",
"id" => "http://remote.org/users/masto_hidden_counters/followers" "id" => "http://remote.org/users/masto_hidden_counters/following"
}, },
headers: HttpRequestMock.activitypub_object_headers() headers: HttpRequestMock.activitypub_object_headers()
) )
@ -1732,7 +1732,7 @@ test "doesn't crash when follower and following counters are hidden" do
json( json(
%{ %{
"@context" => "https://www.w3.org/ns/activitystreams", "@context" => "https://www.w3.org/ns/activitystreams",
"id" => "http://remote.org/users/masto_hidden_counters/following" "id" => "http://remote.org/users/masto_hidden_counters/followers"
}, },
headers: HttpRequestMock.activitypub_object_headers() headers: HttpRequestMock.activitypub_object_headers()
) )

View file

@ -572,6 +572,7 @@ def get("https://social.stopwatchingus-heidelberg.de/.well-known/host-meta", _,
}} }}
end end
# Mastodon status via display URL
def get( def get(
"http://mastodon.example.org/@admin/99541947525187367", "http://mastodon.example.org/@admin/99541947525187367",
_, _,
@ -581,6 +582,23 @@ def get(
{:ok, {:ok,
%Tesla.Env{ %Tesla.Env{
status: 200, status: 200,
url: "http://mastodon.example.org/@admin/99541947525187367",
body: File.read!("test/fixtures/mastodon-note-object.json"),
headers: activitypub_object_headers()
}}
end
# same status via its canonical ActivityPub id
def get(
"http://mastodon.example.org/users/admin/statuses/99541947525187367",
_,
_,
_
) do
{:ok,
%Tesla.Env{
status: 200,
url: "http://mastodon.example.org/users/admin/statuses/99541947525187367",
body: File.read!("test/fixtures/mastodon-note-object.json"), body: File.read!("test/fixtures/mastodon-note-object.json"),
headers: activitypub_object_headers() headers: activitypub_object_headers()
}} }}