Merge remote-tracking branch 'origin/develop' into fork

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-09-02 00:26:35 +02:00
commit acdda8d4df
27 changed files with 699 additions and 397 deletions

View file

View file

@ -0,0 +1 @@
Restrict incoming activities from unknown actors to a subset that does not imply a previous relationship and early rejection of unrecognized activity types.

View file

@ -0,0 +1 @@
Prevent OAuth App flow from creating duplicate entries

View file

@ -0,0 +1 @@
ReceiverWorker will cancel processing jobs instead of retrying if the user cannot be fetched due to 403, 404, or 410 errors or if the account is disabled locally.

View file

@ -0,0 +1 @@
Rich Media preview fetching will skip making an HTTP HEAD request to check a URL for allowed content type and length if the Tesla adapter is Gun or Finch

View file

View file

@ -433,7 +433,7 @@ Response:
* On success: URL of the unfollowed relay
```json
{"https://example.com/relay"}
"https://example.com/relay"
```
## `POST /api/v1/pleroma/admin/users/invite_token`
@ -1193,7 +1193,8 @@ Loads json generated from `config/descriptions.exs`.
- Response:
```json
[
{
"items": [
{
"id": 1234,
"data": {
@ -1206,7 +1207,9 @@ Loads json generated from `config/descriptions.exs`.
"time": 1502812026, // timestamp
"message": "[2017-08-15 15:47:06] @nick0 followed relay: https://example.org/relay" // log message
}
]
],
"total": 1
}
```
## `POST /api/v1/pleroma/admin/reload_emoji`

View file

@ -100,6 +100,36 @@ defmodule Pleroma.Constants do
]
)
const(activity_types,
do: [
"Create",
"Update",
"Delete",
"Follow",
"Accept",
"Reject",
"Add",
"Remove",
"Like",
"Announce",
"Undo",
"Flag",
"EmojiReact"
]
)
const(allowed_activity_types_from_strangers,
do: [
"Block",
"Create",
"Flag",
"Follow",
"Like",
"EmojiReact",
"Announce"
]
)
# basic regex, just there to weed out potential mistakes
# https://datatracker.ietf.org/doc/html/rfc2045#section-5.1
const(mime_regex,

View file

@ -52,6 +52,7 @@ defp adapter_helper do
case adapter() do
Tesla.Adapter.Gun -> AdapterHelper.Gun
Tesla.Adapter.Hackney -> AdapterHelper.Hackney
{Tesla.Adapter.Finch, _} -> AdapterHelper.Finch
_ -> AdapterHelper.Default
end
end
@ -118,4 +119,13 @@ def format_host(host) do
host_charlist
end
end
@spec can_stream? :: bool()
def can_stream? do
case Application.get_env(:tesla, :adapter) do
Tesla.Adapter.Gun -> true
{Tesla.Adapter.Finch, _} -> true
_ -> false
end
end
end

View file

@ -0,0 +1,33 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.AdapterHelper.Finch do
@behaviour Pleroma.HTTP.AdapterHelper
alias Pleroma.Config
alias Pleroma.HTTP.AdapterHelper
@spec options(keyword(), URI.t()) :: keyword()
def options(incoming_opts \\ [], %URI{} = _uri) do
proxy =
[:http, :proxy_url]
|> Config.get()
|> AdapterHelper.format_proxy()
config_opts = Config.get([:http, :adapter], [])
config_opts
|> Keyword.merge(incoming_opts)
|> AdapterHelper.maybe_add_proxy(proxy)
|> maybe_stream()
end
# Finch uses [response: :stream]
defp maybe_stream(opts) do
case Keyword.pop(opts, :stream, nil) do
{true, opts} -> Keyword.put(opts, :response, :stream)
{_, opts} -> opts
end
end
end

View file

@ -32,6 +32,7 @@ def options(incoming_opts \\ [], %URI{} = uri) do
|> AdapterHelper.maybe_add_proxy(proxy)
|> Keyword.merge(incoming_opts)
|> put_timeout()
|> maybe_stream()
end
defp add_scheme_opts(opts, %{scheme: "http"}), do: opts
@ -47,6 +48,14 @@ defp put_timeout(opts) do
Keyword.put(opts, :timeout, recv_timeout)
end
# Gun uses [body_as: :stream]
defp maybe_stream(opts) do
case Keyword.pop(opts, :stream, nil) do
{true, opts} -> Keyword.put(opts, :body_as, :stream)
{_, opts} -> opts
end
end
@spec pool_timeout(pool()) :: non_neg_integer()
def pool_timeout(pool) do
default = Config.get([:pools, :default, :recv_timeout], 5_000)

View file

@ -20,15 +20,13 @@ def safe_put_in(data, keys, value) when is_map(data) and is_list(keys) do
end
def filter_empty_values(data) do
# TODO: Change to Map.filter in Elixir 1.13+
data
|> Enum.filter(fn
|> Map.filter(fn
{_k, nil} -> false
{_k, ""} -> false
{_k, []} -> false
{_k, %{} = v} -> Map.keys(v) != []
{_k, _v} -> true
end)
|> Map.new()
end
end

View file

@ -321,7 +321,7 @@ def inbox(conn, %{"type" => "Create"} = params) do
post_inbox_relayed_create(conn, params)
else
conn
|> put_status(:bad_request)
|> put_status(403)
|> json("Not federating")
end
end

View file

@ -102,7 +102,8 @@ def perform(:incoming_ap_doc, params) do
# NOTE: we use the actor ID to do the containment, this is fine because an
# actor shouldn't be acting on objects outside their own AP server.
with {_, {:ok, _user}} <- {:actor, User.get_or_fetch_by_ap_id(actor)},
with {_, {:ok, user}} <- {:actor, User.get_or_fetch_by_ap_id(actor)},
{:user_active, true} <- {:user_active, match?(true, user.is_active)},
nil <- Activity.normalize(params["id"]),
{_, :ok} <-
{:correct_origin?, Containment.contain_origin_from_id(actor, params)},
@ -121,11 +122,6 @@ def perform(:incoming_ap_doc, params) do
Logger.debug("Unhandled actor #{actor}, #{inspect(e)}")
{:error, e}
{:error, {:validate_object, _}} = e ->
Logger.error("Incoming AP doc validation error: #{inspect(e)}")
Logger.debug(Jason.encode!(params, pretty: true))
e
e ->
# Just drop those for now
Logger.debug(fn -> "Unhandled activity\n" <> Jason.encode!(params, pretty: true) end)

View file

@ -36,8 +36,7 @@ def create(%{body_params: params} = conn, _params) do
|> Map.put(:scopes, scopes)
|> Maps.put_if_present(:user_id, user_id)
with cs <- App.register_changeset(%App{}, app_attrs),
{:ok, app} <- Repo.insert(cs) do
with {:ok, app} <- App.get_or_make(app_attrs) do
render(conn, "show.json", app: app)
end
end

View file

@ -871,19 +871,7 @@ defp build_application(%{"type" => _type, "name" => name, "url" => url}),
defp build_application(_), do: nil
# Workaround for Elixir issue #10771
# Avoid applying URI.merge unless necessary
# TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
# when Elixir 1.12 is the minimum supported version
@spec build_image_url(struct() | nil, struct()) :: String.t() | nil
defp build_image_url(
%URI{scheme: image_scheme, host: image_host} = image_url_data,
%URI{} = _page_url_data
)
when not is_nil(image_scheme) and not is_nil(image_host) do
image_url_data |> to_string
end
@spec build_image_url(URI.t(), URI.t()) :: String.t()
defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
URI.merge(page_url_data, image_url_data) |> to_string
end

View file

@ -67,35 +67,27 @@ def update(id, params) do
with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do
app
|> changeset(params)
|> validate_required([:scopes])
|> Repo.update()
end
end
@doc """
Gets app by attrs or create new with attrs.
And updates the scopes if need.
Updates the attrs if needed.
"""
@spec get_or_make(map(), list(String.t())) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def get_or_make(attrs, scopes) do
with %__MODULE__{} = app <- Repo.get_by(__MODULE__, attrs) do
update_scopes(app, scopes)
@spec get_or_make(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def get_or_make(attrs) do
with %__MODULE__{} = app <- Repo.get_by(__MODULE__, client_name: attrs.client_name) do
__MODULE__.update(app.id, Map.take(attrs, [:scopes, :website]))
else
_e ->
%__MODULE__{}
|> register_changeset(Map.put(attrs, :scopes, scopes))
|> register_changeset(attrs)
|> Repo.insert()
end
end
defp update_scopes(%__MODULE__{} = app, []), do: {:ok, app}
defp update_scopes(%__MODULE__{scopes: scopes} = app, scopes), do: {:ok, app}
defp update_scopes(%__MODULE__{} = app, scopes) do
app
|> change(%{scopes: scopes})
|> Repo.update()
end
@spec search(map()) :: {:ok, [t()], non_neg_integer()}
def search(params) do
query = from(a in __MODULE__)

View file

@ -0,0 +1,89 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.InboxGuardPlug do
import Plug.Conn
import Pleroma.Constants, only: [activity_types: 0, allowed_activity_types_from_strangers: 0]
alias Pleroma.Config
alias Pleroma.User
def init(options) do
options
end
def call(%{assigns: %{valid_signature: true}} = conn, _opts) do
with {_, true} <- {:federating, Config.get!([:instance, :federating])} do
conn
|> filter_activity_types()
else
{:federating, false} ->
conn
|> json(403, "Not federating")
|> halt()
end
end
def call(conn, _opts) do
with {_, true} <- {:federating, Config.get!([:instance, :federating])},
conn = filter_activity_types(conn),
{:known, true} <- {:known, known_actor?(conn)} do
conn
else
{:federating, false} ->
conn
|> json(403, "Not federating")
|> halt()
{:known, false} ->
conn
|> filter_from_strangers()
end
end
# Early rejection of unrecognized types
defp filter_activity_types(%{body_params: %{"type" => type}} = conn) do
with true <- type in activity_types() do
conn
else
_ ->
conn
|> json(400, "Invalid activity type")
|> halt()
end
end
# If signature failed but we know this actor we should
# accept it as we may only need to refetch their public key
# during processing
defp known_actor?(%{body_params: data}) do
case Pleroma.Object.Containment.get_actor(data) |> User.get_cached_by_ap_id() do
%User{} -> true
_ -> false
end
end
# Only permit a subset of activity types from strangers
# or else it will add actors you've never interacted with
# to the database
defp filter_from_strangers(%{body_params: %{"type" => type}} = conn) do
with true <- type in allowed_activity_types_from_strangers() do
conn
else
_ ->
conn
|> json(400, "Invalid activity type for an unknown actor")
|> halt()
end
end
defp json(conn, status, resp) do
json_resp = Jason.encode!(resp)
conn
|> put_resp_content_type("application/json")
|> resp(status, json_resp)
|> halt()
end
end

View file

@ -10,31 +10,40 @@ defmodule Pleroma.Web.RichMedia.Helpers do
@type get_errors :: {:error, :body_too_large | :content_type | :head | :get}
@spec rich_media_get(String.t()) :: {:ok, String.t()} | get_errors()
defp headers do
user_agent =
case Pleroma.Config.get([:rich_media, :user_agent], :default) do
:default ->
Pleroma.Application.user_agent() <> "; Bot"
custom ->
custom
end
[{"user-agent", user_agent}]
end
def rich_media_get(url) do
headers = headers()
case Pleroma.HTTP.AdapterHelper.can_stream?() do
true -> stream(url)
false -> head_first(url)
end
|> handle_result(url)
end
defp stream(url) do
with {_, {:ok, %Tesla.Env{status: 200, body: stream_body, headers: headers}}} <-
{:get, Pleroma.HTTP.get(url, req_headers(), http_options())},
{_, :ok} <- {:content_type, check_content_type(headers)},
{_, :ok} <- {:content_length, check_content_length(headers)},
{:read_stream, {:ok, body}} <- {:read_stream, read_stream(stream_body)} do
{:ok, body}
end
end
defp head_first(url) do
with {_, {:ok, %Tesla.Env{status: 200, headers: headers}}} <-
{:head, Pleroma.HTTP.head(url, headers, http_options())},
{:head, Pleroma.HTTP.head(url, req_headers(), http_options())},
{_, :ok} <- {:content_type, check_content_type(headers)},
{_, :ok} <- {:content_length, check_content_length(headers)},
{_, {:ok, %Tesla.Env{status: 200, body: body}}} <-
{:get, Pleroma.HTTP.get(url, headers, http_options())} do
{:get, Pleroma.HTTP.get(url, req_headers(), http_options())} do
{:ok, body}
else
end
end
defp handle_result(result, url) do
case result do
{:ok, body} ->
{:ok, body}
{:head, _} ->
Logger.debug("Rich media error for #{url}: HTTP HEAD failed")
{:error, :head}
@ -43,8 +52,12 @@ def rich_media_get(url) do
Logger.debug("Rich media error for #{url}: content-type is #{type}")
{:error, :content_type}
{:content_length, {_, length}} ->
Logger.debug("Rich media error for #{url}: content-length is #{length}")
{:content_length, :error} ->
Logger.debug("Rich media error for #{url}: content-length exceeded")
{:error, :body_too_large}
{:read_stream, :error} ->
Logger.debug("Rich media error for #{url}: content-length exceeded")
{:error, :body_too_large}
{:get, _} ->
@ -73,7 +86,7 @@ defp check_content_length(headers) do
{_, maybe_content_length} ->
case Integer.parse(maybe_content_length) do
{content_length, ""} when content_length <= max_body -> :ok
{_, ""} -> {:error, maybe_content_length}
{_, ""} -> :error
_ -> :ok
end
@ -82,13 +95,46 @@ defp check_content_length(headers) do
end
end
defp http_options do
timeout = Config.get!([:rich_media, :timeout])
defp read_stream(stream) do
max_body = Keyword.get(http_options(), :max_body)
try do
result =
Stream.transform(stream, 0, fn chunk, total_bytes ->
new_total = total_bytes + byte_size(chunk)
if new_total > max_body do
raise("Exceeds max body limit of #{max_body}")
else
{[chunk], new_total}
end
end)
|> Enum.into(<<>>)
{:ok, result}
rescue
_ -> :error
end
end
defp http_options do
[
pool: :rich_media,
max_body: Config.get([:rich_media, :max_body], 5_000_000),
tesla_middleware: [{Tesla.Middleware.Timeout, timeout: timeout}]
stream: true
]
end
defp req_headers do
user_agent =
case Pleroma.Config.get([:rich_media, :user_agent], :default) do
:default ->
Pleroma.Application.user_agent() <> "; Bot"
custom ->
custom
end
[{"user-agent", user_agent}]
end
end

View file

@ -217,6 +217,10 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug)
end
pipeline :inbox_guard do
plug(Pleroma.Web.Plugs.InboxGuardPlug)
end
pipeline :static_fe do
plug(Pleroma.Web.Plugs.StaticFEPlug)
end
@ -1077,7 +1081,7 @@ defmodule Pleroma.Web.Router do
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through(:activitypub)
pipe_through([:activitypub, :inbox_guard])
post("/inbox", ActivityPubController, :inbox)
post("/users/:nickname/inbox", ActivityPubController, :inbox)
end

View file

@ -33,7 +33,7 @@ def perform(%Job{
query_string: query_string
}
with {:ok, %User{} = _actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]),
with {:ok, %User{}} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]),
{:ok, _public_key} <- Signature.refetch_public_key(conn_data),
{:signature, true} <- {:signature, Signature.validate_signature(conn_data)},
{:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
@ -56,17 +56,29 @@ def timeout(%_{args: %{"timeout" => timeout}}), do: timeout
def timeout(_job), do: :timer.seconds(5)
defp process_errors({:error, {:error, _} = error}), do: process_errors(error)
defp process_errors(errors) do
case errors do
{:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed}
{:error, :already_present} -> {:cancel, :already_present}
{:error, {:validate_object, _} = reason} -> {:cancel, reason}
{:error, {:error, {:validate, {:error, _changeset} = reason}}} -> {:cancel, reason}
{:error, {:reject, _} = reason} -> {:cancel, reason}
{:signature, false} -> {:cancel, :invalid_signature}
{:error, "Object has been deleted"} = reason -> {:cancel, reason}
{:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason}
# User fetch failures
{:error, :not_found} = reason -> {:cancel, reason}
{:error, :forbidden} = reason -> {:cancel, reason}
# Inactive user
{:error, {:user_active, false} = reason} -> {:cancel, reason}
# Validator will error and return a changeset error
# e.g., duplicate activities or if the object was deleted
{:error, {:validate, {:error, _changeset} = reason}} -> {:cancel, reason}
# Duplicate detection during Normalization
{:error, :already_present} -> {:cancel, :already_present}
# MRFs will return a reject
{:error, {:reject, _} = reason} -> {:cancel, reason}
# HTTP Sigs
{:signature, false} -> {:cancel, :invalid_signature}
# Origin / URL validation failed somewhere possibly due to spoofing
{:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed}
# Unclear if this can be reached
{:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason}
# Catchall
{:error, _} = e -> e
e -> {:error, e}
end

View file

@ -1,117 +0,0 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"Curve25519Key": "toot:Curve25519Key",
"Device": "toot:Device",
"Ed25519Key": "toot:Ed25519Key",
"Ed25519Signature": "toot:Ed25519Signature",
"EncryptedMessage": "toot:EncryptedMessage",
"PropertyValue": "schema:PropertyValue",
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"cipherText": "toot:cipherText",
"claim": {
"@id": "toot:claim",
"@type": "@id"
},
"deviceId": "toot:deviceId",
"devices": {
"@id": "toot:devices",
"@type": "@id"
},
"discoverable": "toot:discoverable",
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"featuredTags": {
"@id": "toot:featuredTags",
"@type": "@id"
},
"fingerprintKey": {
"@id": "toot:fingerprintKey",
"@type": "@id"
},
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
},
"identityKey": {
"@id": "toot:identityKey",
"@type": "@id"
},
"indexable": "toot:indexable",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"memorial": "toot:memorial",
"messageFranking": "toot:messageFranking",
"messageType": "toot:messageType",
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"publicKeyBase64": "toot:publicKeyBase64",
"schema": "http://schema.org#",
"suspended": "toot:suspended",
"toot": "http://joinmastodon.org/ns#",
"value": "schema:value"
}
],
"attachment": [
{
"name": "Website",
"type": "PropertyValue",
"value": "<a href=\"https://bastianallgeier.com\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">bastianallgeier.com</span><span class=\"invisible\"></span></a>"
},
{
"name": "Project",
"type": "PropertyValue",
"value": "<a href=\"https://getkirby.com\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">getkirby.com</span><span class=\"invisible\"></span></a>"
},
{
"name": "Github",
"type": "PropertyValue",
"value": "<a href=\"https://github.com/bastianallgeier\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"\">github.com/bastianallgeier</span><span class=\"invisible\"></span></a>"
}
],
"devices": "https://mastodon.social/users/bastianallgeier/collections/devices",
"discoverable": true,
"endpoints": {
"sharedInbox": "https://mastodon.social/inbox"
},
"featured": "https://mastodon.social/users/bastianallgeier/collections/featured",
"featuredTags": "https://mastodon.social/users/bastianallgeier/collections/tags",
"followers": "https://mastodon.social/users/bastianallgeier/followers",
"following": "https://mastodon.social/users/bastianallgeier/following",
"icon": {
"mediaType": "image/jpeg",
"type": "Image",
"url": "https://files.mastodon.social/accounts/avatars/000/007/393/original/0180a20079617c71.jpg"
},
"id": "https://mastodon.social/users/bastianallgeier",
"image": {
"mediaType": "image/jpeg",
"type": "Image",
"url": "https://files.mastodon.social/accounts/headers/000/007/393/original/13d644ab46d50478.jpeg"
},
"inbox": "https://mastodon.social/users/bastianallgeier/inbox",
"indexable": false,
"manuallyApprovesFollowers": false,
"memorial": false,
"name": "Bastian Allgeier",
"outbox": "https://mastodon.social/users/bastianallgeier/outbox",
"preferredUsername": "bastianallgeier",
"publicKey": {
"id": "https://mastodon.social/users/bastianallgeier#main-key",
"owner": "https://mastodon.social/users/bastianallgeier",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3fz+hpgVztO9z6HUhyzv\nwP++ERBBoIwSLKf1TyIM8bvzGFm2YXaO5uxu1HvumYFTYc3ACr3q4j8VUb7NMxkQ\nlzu4QwPjOFJ43O+fY+HSPORXEDW5fXDGC5DGpox4+i08LxRmx7L6YPRUSUuPN8nI\nWyq1Qsq1zOQrNY/rohMXkBdSXxqC3yIRqvtLt4otCgay/5tMogJWkkS6ZKyFhb9z\nwVVy1fsbV10c9C+SHy4NH26CKaTtpTYLRBMjhTCS8bX8iDSjGIf2aZgYs1ir7gEz\n9wf5CvLiENmVWGwm64t6KSEAkA4NJ1hzgHUZPCjPHZE2SmhO/oHaxokTzqtbbENJ\n1QIDAQAB\n-----END PUBLIC KEY-----\n"
},
"published": "2016-11-01T00:00:00Z",
"summary": "<p>Designer &amp; developer. Creator of Kirby CMS</p>",
"tag": [],
"type": "Person",
"url": "https://mastodon.social/@bastianallgeier"
}

View file

@ -1,62 +1,109 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"claim": {
"@id": "toot:claim",
"@type": "@id"
},
"memorial": "toot:memorial",
"atomUri": "ostatus:atomUri",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"blurhash": "toot:blurhash",
"conversation": "ostatus:conversation",
"ostatus": "http://ostatus.org#",
"discoverable": "toot:discoverable",
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
},
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"ostatus": "http://ostatus.org#",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount"
}
],
"atomUri": "https://chaos.social/users/distantnative/statuses/109336635639931467",
"attachment": [
{
"blurhash": "UAK1zS00OXIUxuMxIUM{?b-:-;W:Di?b%2M{",
"height": 960,
"mediaType": "image/jpeg",
"name": null,
"type": "Document",
"url": "https://assets.chaos.social/media_attachments/files/109/336/634/286/114/657/original/2e6122063d8bfb26.jpeg",
"width": 346
}
],
"attributedTo": "https://chaos.social/users/distantnative",
"cc": [
"https://chaos.social/users/distantnative/followers"
],
"content": "<p>Favorite piece of anthropology meta discourse.</p>",
"contentMap": {
"en": "<p>Favorite piece of anthropology meta discourse.</p>"
"votersCount": "toot:votersCount",
"Hashtag": "as:Hashtag",
"Emoji": "toot:Emoji",
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"conversation": "tag:chaos.social,2022-11-13:objectId=71843781:objectType=Conversation",
"id": "https://chaos.social/users/distantnative/statuses/109336635639931467",
"sensitive": "as:sensitive",
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"Device": "toot:Device",
"schema": "http://schema.org#",
"toot": "http://joinmastodon.org/ns#",
"cipherText": "toot:cipherText",
"suspended": "toot:suspended",
"messageType": "toot:messageType",
"featuredTags": {
"@id": "toot:featuredTags",
"@type": "@id"
},
"Curve25519Key": "toot:Curve25519Key",
"deviceId": "toot:deviceId",
"Ed25519Signature": "toot:Ed25519Signature",
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"devices": {
"@id": "toot:devices",
"@type": "@id"
},
"value": "schema:value",
"PropertyValue": "schema:PropertyValue",
"messageFranking": "toot:messageFranking",
"publicKeyBase64": "toot:publicKeyBase64",
"identityKey": {
"@id": "toot:identityKey",
"@type": "@id"
},
"Ed25519Key": "toot:Ed25519Key",
"indexable": "toot:indexable",
"EncryptedMessage": "toot:EncryptedMessage",
"fingerprintKey": {
"@id": "toot:fingerprintKey",
"@type": "@id"
}
}
],
"actor": "https://phpc.social/users/denniskoch",
"cc": [],
"id": "https://phpc.social/users/denniskoch/statuses/112847382711461301/activity",
"inReplyTo": null,
"inReplyToAtomUri": null,
"published": "2022-11-13T13:04:20Z",
"replies": {
"first": {
"items": [],
"next": "https://chaos.social/users/distantnative/statuses/109336635639931467/replies?only_other_accounts=true&page=true",
"partOf": "https://chaos.social/users/distantnative/statuses/109336635639931467/replies",
"type": "CollectionPage"
},
"id": "https://chaos.social/users/distantnative/statuses/109336635639931467/replies",
"type": "Collection"
"object": {
"atomUri": "https://phpc.social/users/denniskoch/statuses/112847382711461301",
"attachment": [],
"attributedTo": "https://phpc.social/users/denniskoch",
"cc": [],
"content": "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://mastodon.social/@bastianallgeier\" class=\"u-url mention\">@<span>bastianallgeier</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://chaos.social/@distantnative\" class=\"u-url mention\">@<span>distantnative</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://fosstodon.org/@kev\" class=\"u-url mention\">@<span>kev</span></a></span> Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.</p>",
"contentMap": {
"en": "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://mastodon.social/@bastianallgeier\" class=\"u-url mention\">@<span>bastianallgeier</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://chaos.social/@distantnative\" class=\"u-url mention\">@<span>distantnative</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://fosstodon.org/@kev\" class=\"u-url mention\">@<span>kev</span></a></span> Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.</p>"
},
"conversation": "tag:mastodon.social,2024-07-25:objectId=760068442:objectType=Conversation",
"id": "https://phpc.social/users/denniskoch/statuses/112847382711461301",
"published": "2024-07-25T13:33:29Z",
"replies": null,
"sensitive": false,
"summary": null,
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note",
"url": "https://chaos.social/@distantnative/109336635639931467"
"url": "https://phpc.social/@denniskoch/112847382711461301"
},
"published": "2024-07-25T13:33:29Z",
"signature": {
"created": "2024-07-25T13:33:29Z",
"creator": "https://phpc.social/users/denniskoch#main-key",
"signatureValue": "slz9BKJzd2n1S44wdXGOU+bV/wsskdgAaUpwxj8R16mYOL8+DTpE6VnfSKoZGsBBJT8uG5gnVfVEz1YsTUYtymeUgLMh7cvd8VnJnZPS+oixbmBRVky/Myf91TEgQQE7G4vDmTdB4ii54hZrHcOOYYf5FKPNRSkMXboKA6LMqNtekhbI+JTUJYIB02WBBK6PUyo15f6B1RJ6HGWVgud9NE0y1EZXfrkqUt682p8/9D49ORf7AwjXUJibKic2RbPvhEBj70qUGfBm4vvgdWhSUn1IG46xh+U0+NrTSUED82j1ZVOeua/2k/igkGs8cSBkY35quXTkPz6gbqCCH66CuA==",
"type": "RsaSignature2017"
},
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Create"
}

View file

@ -675,7 +675,7 @@ test "accept follow activity", %{conn: conn} do
end
test "without valid signature, " <>
"it only accepts Create activities and requires enabled federation",
"it accepts Create activities and requires enabled federation",
%{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
non_create_data = File.read!("test/fixtures/mastodon-announce.json") |> Jason.decode!()
@ -702,6 +702,54 @@ test "without valid signature, " <>
|> json_response(400)
end
# When activity is delivered to the inbox and we cannot immediately verify signature
# we capture all the params and process it later in the Oban job.
# Once we begin processing it through Oban we risk fetching the actor to validate the
# activity which just leads to inserting a new user to process a Delete not relevant to us.
test "Activities of certain types from an unknown actor are discarded", %{conn: conn} do
example_bad_types =
Pleroma.Constants.activity_types() --
Pleroma.Constants.allowed_activity_types_from_strangers()
Enum.each(example_bad_types, fn bad_type ->
params =
%{
"type" => bad_type,
"actor" => "https://unknown.mastodon.instance/users/somebody"
}
|> Jason.encode!()
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", params)
|> json_response(400)
assert all_enqueued() == []
end)
end
test "Unknown activity types are discarded", %{conn: conn} do
unknown_types = ["Poke", "Read", "Dazzle"]
Enum.each(unknown_types, fn bad_type ->
params =
%{
"type" => bad_type,
"actor" => "https://unknown.mastodon.instance/users/somebody"
}
|> Jason.encode!()
conn
|> assign(:valid_signature, true)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", params)
|> json_response(400)
assert all_enqueued() == []
end)
end
test "accepts Add/Remove activities", %{conn: conn} do
object_id = "c61d6733-e256-4fe1-ab13-1e369789423f"

View file

@ -89,4 +89,114 @@ test "creates an oauth app with a user", %{conn: conn} do
assert expected == json_response_and_validate_schema(conn, 200)
assert app.user_id == user.id
end
test "creates an oauth app without a user", %{conn: conn} do
app_attrs = build(:oauth_app)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/apps", %{
client_name: app_attrs.client_name,
redirect_uris: app_attrs.redirect_uris
})
[app] = Repo.all(App)
expected = %{
"name" => app.client_name,
"website" => app.website,
"client_id" => app.client_id,
"client_secret" => app.client_secret,
"id" => app.id |> to_string(),
"redirect_uri" => app.redirect_uris,
"vapid_key" => Push.vapid_config() |> Keyword.get(:public_key)
}
assert expected == json_response_and_validate_schema(conn, 200)
end
test "does not duplicate apps with the same client name", %{conn: conn} do
client_name = "BleromaSE"
redirect_uris = "https://bleroma.app/oauth-callback"
for _i <- 1..3 do
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/apps", %{
client_name: client_name,
redirect_uris: redirect_uris
})
|> json_response_and_validate_schema(200)
end
apps = Repo.all(App)
assert length(apps) == 1
assert List.first(apps).client_name == client_name
assert List.first(apps).redirect_uris == redirect_uris
end
test "app scopes can be updated", %{conn: conn} do
client_name = "BleromaSE"
redirect_uris = "https://bleroma.app/oauth-callback"
website = "https://bleromase.com"
scopes = "read write"
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/apps", %{
client_name: client_name,
redirect_uris: redirect_uris,
website: website,
scopes: scopes
})
|> json_response_and_validate_schema(200)
assert List.first(Repo.all(App)).scopes == String.split(scopes, " ")
updated_scopes = "read write push"
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/apps", %{
client_name: client_name,
redirect_uris: redirect_uris,
website: website,
scopes: updated_scopes
})
|> json_response_and_validate_schema(200)
assert List.first(Repo.all(App)).scopes == String.split(updated_scopes, " ")
end
test "app website URL can be updated", %{conn: conn} do
client_name = "BleromaSE"
redirect_uris = "https://bleroma.app/oauth-callback"
website = "https://bleromase.com"
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/apps", %{
client_name: client_name,
redirect_uris: redirect_uris,
website: website
})
|> json_response_and_validate_schema(200)
assert List.first(Repo.all(App)).website == website
updated_website = "https://bleromase2ultimateedition.com"
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/apps", %{
client_name: client_name,
redirect_uris: redirect_uris,
website: updated_website
})
|> json_response_and_validate_schema(200)
assert List.first(Repo.all(App)).website == updated_website
end
end

View file

@ -12,20 +12,23 @@ defmodule Pleroma.Web.OAuth.AppTest do
test "gets exist app" do
attrs = %{client_name: "Mastodon-Local", redirect_uris: "."}
app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]}))
{:ok, %App{} = exist_app} = App.get_or_make(attrs, [])
{:ok, %App{} = exist_app} = App.get_or_make(attrs)
assert exist_app == app
end
test "make app" do
attrs = %{client_name: "Mastodon-Local", redirect_uris: "."}
{:ok, %App{} = app} = App.get_or_make(attrs, ["write"])
attrs = %{client_name: "Mastodon-Local", redirect_uris: ".", scopes: ["write"]}
{:ok, %App{} = app} = App.get_or_make(attrs)
assert app.scopes == ["write"]
end
test "gets exist app and updates scopes" do
attrs = %{client_name: "Mastodon-Local", redirect_uris: "."}
app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]}))
{:ok, %App{} = exist_app} = App.get_or_make(attrs, ["read", "write", "follow", "push"])
attrs = %{client_name: "Mastodon-Local", redirect_uris: ".", scopes: ["read", "write"]}
app = insert(:oauth_app, attrs)
{:ok, %App{} = exist_app} =
App.get_or_make(%{attrs | scopes: ["read", "write", "follow", "push"]})
assert exist_app.id == app.id
assert exist_app.scopes == ["read", "write", "follow", "push"]
end

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
import Mock
import Pleroma.Factory
alias Pleroma.User
alias Pleroma.Web.Federator
alias Pleroma.Workers.ReceiverWorker
@ -51,25 +52,106 @@ test "it does not retry duplicates" do
})
end
describe "cancels on a failed user fetch" do
setup do
Tesla.Mock.mock(fn
%{url: "https://springfield.social/users/bart"} ->
%Tesla.Env{
status: 403,
body: ""
}
%{url: "https://springfield.social/users/troymcclure"} ->
%Tesla.Env{
status: 404,
body: ""
}
%{url: "https://springfield.social/users/hankscorpio"} ->
%Tesla.Env{
status: 410,
body: ""
}
end)
end
test "when request returns a 403" do
params =
insert(:note_activity).data
|> Map.put("actor", "https://springfield.social/users/bart")
{:ok, oban_job} =
Federator.incoming_ap_doc(%{
method: "POST",
req_headers: [],
request_path: "/inbox",
params: params,
query_string: ""
})
assert {:cancel, {:error, :forbidden}} = ReceiverWorker.perform(oban_job)
end
test "when request returns a 404" do
params =
insert(:note_activity).data
|> Map.put("actor", "https://springfield.social/users/troymcclure")
{:ok, oban_job} =
Federator.incoming_ap_doc(%{
method: "POST",
req_headers: [],
request_path: "/inbox",
params: params,
query_string: ""
})
assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job)
end
test "when request returns a 410" do
params =
insert(:note_activity).data
|> Map.put("actor", "https://springfield.social/users/hankscorpio")
{:ok, oban_job} =
Federator.incoming_ap_doc(%{
method: "POST",
req_headers: [],
request_path: "/inbox",
params: params,
query_string: ""
})
assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job)
end
test "when user account is disabled" do
user = insert(:user)
fake_activity = URI.parse(user.ap_id) |> Map.put(:path, "/fake-activity") |> to_string
params =
insert(:note_activity, user: user).data
|> Map.put("id", fake_activity)
{:ok, %User{}} = User.set_activation(user, false)
{:ok, oban_job} =
Federator.incoming_ap_doc(%{
method: "POST",
req_headers: [],
request_path: "/inbox",
params: params,
query_string: ""
})
assert {:cancel, {:user_active, false}} = ReceiverWorker.perform(oban_job)
end
end
test "it can validate the signature" do
Tesla.Mock.mock(fn
%{url: "https://mastodon.social/users/bastianallgeier"} ->
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/bastianallgeier.json"),
headers: [{"content-type", "application/activity+json"}]
}
%{url: "https://mastodon.social/users/bastianallgeier/collections/featured"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body:
File.read!("test/fixtures/users_mock/masto_featured.json")
|> String.replace("{{domain}}", "mastodon.social")
|> String.replace("{{nickname}}", "bastianallgeier")
}
%{url: "https://phpc.social/users/denniskoch"} ->
%Tesla.Env{
status: 200,
@ -86,136 +168,10 @@ test "it can validate the signature" do
|> String.replace("{{domain}}", "phpc.social")
|> String.replace("{{nickname}}", "denniskoch")
}
%{url: "https://mastodon.social/users/bastianallgeier/statuses/112846516276907281"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body: File.read!("test/fixtures/receiver_worker_signature_activity.json")
}
end)
params = %{
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
%{
"claim" => %{"@id" => "toot:claim", "@type" => "@id"},
"memorial" => "toot:memorial",
"atomUri" => "ostatus:atomUri",
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"blurhash" => "toot:blurhash",
"ostatus" => "http://ostatus.org#",
"discoverable" => "toot:discoverable",
"focalPoint" => %{"@container" => "@list", "@id" => "toot:focalPoint"},
"votersCount" => "toot:votersCount",
"Hashtag" => "as:Hashtag",
"Emoji" => "toot:Emoji",
"alsoKnownAs" => %{"@id" => "as:alsoKnownAs", "@type" => "@id"},
"sensitive" => "as:sensitive",
"movedTo" => %{"@id" => "as:movedTo", "@type" => "@id"},
"inReplyToAtomUri" => "ostatus:inReplyToAtomUri",
"conversation" => "ostatus:conversation",
"Device" => "toot:Device",
"schema" => "http://schema.org#",
"toot" => "http://joinmastodon.org/ns#",
"cipherText" => "toot:cipherText",
"suspended" => "toot:suspended",
"messageType" => "toot:messageType",
"featuredTags" => %{"@id" => "toot:featuredTags", "@type" => "@id"},
"Curve25519Key" => "toot:Curve25519Key",
"deviceId" => "toot:deviceId",
"Ed25519Signature" => "toot:Ed25519Signature",
"featured" => %{"@id" => "toot:featured", "@type" => "@id"},
"devices" => %{"@id" => "toot:devices", "@type" => "@id"},
"value" => "schema:value",
"PropertyValue" => "schema:PropertyValue",
"messageFranking" => "toot:messageFranking",
"publicKeyBase64" => "toot:publicKeyBase64",
"identityKey" => %{"@id" => "toot:identityKey", "@type" => "@id"},
"Ed25519Key" => "toot:Ed25519Key",
"indexable" => "toot:indexable",
"EncryptedMessage" => "toot:EncryptedMessage",
"fingerprintKey" => %{"@id" => "toot:fingerprintKey", "@type" => "@id"}
}
],
"actor" => "https://phpc.social/users/denniskoch",
"cc" => [
"https://phpc.social/users/denniskoch/followers",
"https://mastodon.social/users/bastianallgeier",
"https://chaos.social/users/distantnative",
"https://fosstodon.org/users/kev"
],
"id" => "https://phpc.social/users/denniskoch/statuses/112847382711461301/activity",
"object" => %{
"atomUri" => "https://phpc.social/users/denniskoch/statuses/112847382711461301",
"attachment" => [],
"attributedTo" => "https://phpc.social/users/denniskoch",
"cc" => [
"https://phpc.social/users/denniskoch/followers",
"https://mastodon.social/users/bastianallgeier",
"https://chaos.social/users/distantnative",
"https://fosstodon.org/users/kev"
],
"content" =>
"<p><span class=\"h-card\" translate=\"no\"><a href=\"https://mastodon.social/@bastianallgeier\" class=\"u-url mention\">@<span>bastianallgeier</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://chaos.social/@distantnative\" class=\"u-url mention\">@<span>distantnative</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://fosstodon.org/@kev\" class=\"u-url mention\">@<span>kev</span></a></span> Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.</p>",
"contentMap" => %{
"en" =>
"<p><span class=\"h-card\" translate=\"no\"><a href=\"https://mastodon.social/@bastianallgeier\" class=\"u-url mention\">@<span>bastianallgeier</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://chaos.social/@distantnative\" class=\"u-url mention\">@<span>distantnative</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://fosstodon.org/@kev\" class=\"u-url mention\">@<span>kev</span></a></span> Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.</p>"
},
"conversation" =>
"tag:mastodon.social,2024-07-25:objectId=760068442:objectType=Conversation",
"id" => "https://phpc.social/users/denniskoch/statuses/112847382711461301",
"inReplyTo" =>
"https://mastodon.social/users/bastianallgeier/statuses/112846516276907281",
"inReplyToAtomUri" =>
"https://mastodon.social/users/bastianallgeier/statuses/112846516276907281",
"published" => "2024-07-25T13:33:29Z",
"replies" => %{
"first" => %{
"items" => [],
"next" =>
"https://phpc.social/users/denniskoch/statuses/112847382711461301/replies?only_other_accounts=true&page=true",
"partOf" =>
"https://phpc.social/users/denniskoch/statuses/112847382711461301/replies",
"type" => "CollectionPage"
},
"id" => "https://phpc.social/users/denniskoch/statuses/112847382711461301/replies",
"type" => "Collection"
},
"sensitive" => false,
"tag" => [
%{
"href" => "https://mastodon.social/users/bastianallgeier",
"name" => "@bastianallgeier@mastodon.social",
"type" => "Mention"
},
%{
"href" => "https://chaos.social/users/distantnative",
"name" => "@distantnative@chaos.social",
"type" => "Mention"
},
%{
"href" => "https://fosstodon.org/users/kev",
"name" => "@kev@fosstodon.org",
"type" => "Mention"
}
],
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"type" => "Note",
"url" => "https://phpc.social/@denniskoch/112847382711461301"
},
"published" => "2024-07-25T13:33:29Z",
"signature" => %{
"created" => "2024-07-25T13:33:29Z",
"creator" => "https://phpc.social/users/denniskoch#main-key",
"signatureValue" =>
"slz9BKJzd2n1S44wdXGOU+bV/wsskdgAaUpwxj8R16mYOL8+DTpE6VnfSKoZGsBBJT8uG5gnVfVEz1YsTUYtymeUgLMh7cvd8VnJnZPS+oixbmBRVky/Myf91TEgQQE7G4vDmTdB4ii54hZrHcOOYYf5FKPNRSkMXboKA6LMqNtekhbI+JTUJYIB02WBBK6PUyo15f6B1RJ6HGWVgud9NE0y1EZXfrkqUt682p8/9D49ORf7AwjXUJibKic2RbPvhEBj70qUGfBm4vvgdWhSUn1IG46xh+U0+NrTSUED82j1ZVOeua/2k/igkGs8cSBkY35quXTkPz6gbqCCH66CuA==",
"type" => "RsaSignature2017"
},
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"type" => "Create"
}
params =
File.read!("test/fixtures/receiver_worker_signature_activity.json") |> Jason.decode!()
req_headers = [
["accept-encoding", "gzip"],
@ -245,4 +201,46 @@ test "it can validate the signature" do
assert {:ok, %Pleroma.Activity{}} = ReceiverWorker.perform(oban_job)
end
test "cancels due to origin containment" do
params =
insert(:note_activity).data
|> Map.put("id", "https://notorigindomain.com/activity")
{:ok, oban_job} =
Federator.incoming_ap_doc(%{
method: "POST",
req_headers: [],
request_path: "/inbox",
params: params,
query_string: ""
})
assert {:cancel, :origin_containment_failed} = ReceiverWorker.perform(oban_job)
end
test "canceled due to deleted object" do
params =
insert(:announce_activity).data
|> Map.put("object", "http://localhost:4001/deleted")
Tesla.Mock.mock(fn
%{url: "http://localhost:4001/deleted"} ->
%Tesla.Env{
status: 404,
body: ""
}
end)
{:ok, oban_job} =
Federator.incoming_ap_doc(%{
method: "POST",
req_headers: [],
request_path: "/inbox",
params: params,
query_string: ""
})
assert {:cancel, _} = ReceiverWorker.perform(oban_job)
end
end