Threads's "Federation"
It's the holidays, and you know what that means. … I've had enough time off from programming professionally that now I want to do some of it for fun!
I previously wrote a tool that syncs statuses from Mastodon to my FeoBlog instance. I was thinking about making some updates to that, and then I remembered that I'd read that Threads got around to testing their federation support.
Maybe I can update my tool to also sync posts from Threads, then? Let's test that out and see how their interoperability is coming along. (Spoiler: So far, not great.)
Nushell's built-in support for HTTP requests, JSON, and structured data makes it pretty nice for doing this kind of experimentation, so that's what I'm using here. Let's start by fetching a "status" with the Mastodon REST API:
def getStatus [id: int, --server = "mastodon.social"] {
http get $"https://($server)/api/v1/statuses/($id)"
}
let status = getStatus 111656384454261788
$status | select uri created_at in_reply_to_id
This works, and gives us back (among other data):
╭────────────────┬──────────────────────────────────────────────────────────────────────╮
│ uri │ https://mastodon.social/users/Iamgroot11/statuses/111656384454261788 │
│ created_at │ 2023-12-28T05:26:57.877Z │
│ in_reply_to_id │ 111656368951064667 │
╰────────────────┴──────────────────────────────────────────────────────────────────────╯
So does this, even though that status is coming from a different server. (Yay, federation!)
let status2 = getStatus $status.in_reply_to_id
$status2 | select uri created_at in_reply_to_id
╭────────────────┬───────────────────────────────────────────────────────────────╮
│ uri │ https://spacey.space/users/kmccoy/statuses/111656368910605303 │
│ created_at │ 2023-12-28T05:23:00.000Z │
│ in_reply_to_id │ 111656361314256557 │
╰────────────────┴───────────────────────────────────────────────────────────────╯
We can use the ActivityPub API for retrieving objects from remote servers to confirm that the version we got from our server matches the one published by this user:
def getActivityStream [uri] {(
http get $uri
--headers [
accept
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
]
| from json
)}
let remote_status2 = getActivityStream $status2.uri
$remote_status2 | select url published inReplyTo
╭───────────┬──────────────────────────────────────────────────────────────────╮
│ url │ https://spacey.space/@kmccoy/111656368910605303 │
│ published │ 2023-12-28T05:23:00Z │
│ inReplyTo │ https://www.threads.net/ap/users/mosseri/post/17928407810714224/ │
╰───────────┴──────────────────────────────────────────────────────────────────╯
But there's no such luck with Threads. We can fetch the status from mastodon.social
:
let status3 = getStatus $status2.in_reply_to_id
$status3 | select uri created_at in_reply_to_id
╭────────────────┬──────────────────────────────────────────────────────────────────╮
│ uri │ https://www.threads.net/ap/users/mosseri/post/17928407810714224/ │
│ created_at │ 2023-12-28T05:15:53.000Z │
│ in_reply_to_id │ │
╰────────────────┴──────────────────────────────────────────────────────────────────╯
But threads.net
seems to be misrepresenting that it has an ActivityPub (ActivityStream) object at that URL/URI:
getActivityStream $status3.uri
Error: nu::shell::network_failure
× Network failure
╭─[entry #182:1:1]
1 │ def getActivityStream [uri] {(
2 │ http get $uri
· ──┬─
· ╰── Requested file not found (404): "https://www.threads.net/ap/users/mosseri/post/17928407810714224/"
3 │ --headers [
╰────
I discovered that the URL (not URI) advertises that it is an "activity":
http get $status3.url | parse --regex '(<link .*?>)' | find -r activity | get capture0 | each { from xml }
╭──────┬──────────────────────────────────────────────────────────────┬────────────────╮
│ tag │ attributes │ content │
├──────┼──────────────────────────────────────────────────────────────┼────────────────┤
│ link │ ╭──────┬───────────────────────────────────────────────────╮ │ [list 0 items] │
│ │ │ href │ https://www.threads.net/@mosseri/post/C1YndCeuddr │ │ │
│ │ │ type │ application/activity+json │ │ │
│ │ ╰──────┴───────────────────────────────────────────────────╯ │ │
╰──────┴──────────────────────────────────────────────────────────────┴────────────────╯
… but that URL doesn't serve an Activity either. (It just ignores our Accept
header and gives back a Content-Type: text/html; charset="utf-8"
.)
Summary
So what we seem to have here is Threads doing juuuust enough work to shove ActivityPub messages into Mastodon. But it's certainly not yet supporting enough of the ActivityStream/ActivityPub API to validate against spoofing attacks, as the W3C docs recommend:
Servers SHOULD validate the content they receive to avoid content spoofing attacks. (A server should do something at least as robust as checking that the object appears as received at its origin, but mechanisms such as checking signatures would be better if available). No particular mechanism for verification is authoritatively specified by this document, [...]
Is Mastodon just accepting those objects from a peered server without any sort of validation that they match what that peer serves for that activity? That would allow Threads to inject ads into (or otherwise modify) statuses that it pushes into Mastodon.
Or maybe Threads is only responding to ActivityStream requests if they're coming from a peer server that has been explicitly granted access? That would let them "federate" with peers on their terms, while not letting us plebs peek into the walled garden of data.
I'll reservedly concede that this may just be the current unfinished state of Threads's support for ActivityPub/ActivityStreams. But let's wait and see how much they actually implement, and how interoperable it ends up being.