Merge branch 'code/majorrig/mastodon-mention-spam-wiki'

This commit is contained in:
Marcus Summers 2026-06-22 13:50:21 -04:00
commit 8d9bd34118
3 changed files with 172 additions and 0 deletions

View file

@ -38,6 +38,7 @@ Guides for running your own services at home, including Docker, reverse proxies,
- [Mastodon Federation](services/mastodon-federation.md) - [Mastodon Federation](services/mastodon-federation.md)
- [Mastodon `--prune-profiles` Trap](services/mastodon-prune-profiles-trap.md) - [Mastodon `--prune-profiles` Trap](services/mastodon-prune-profiles-trap.md)
- [Mastodon on S3 — Silent Upload Failures](services/mastodon-s3-acl-upload-failures.md) - [Mastodon on S3 — Silent Upload Failures](services/mastodon-s3-acl-upload-failures.md)
- [Mastodon — Triaging Crowdfunding / Mention-Spam Accounts](services/mastodon-mention-spam-crowdfunding.md)
- [Ghost SMTP via Mailgun](services/ghost-smtp-mailgun-setup.md) - [Ghost SMTP via Mailgun](services/ghost-smtp-mailgun-setup.md)
- [Updating n8n Docker](services/updating-n8n-docker.md) - [Updating n8n Docker](services/updating-n8n-docker.md)
- [Claude Code Remote Control](services/claude-code-remote-control.md) - [Claude Code Remote Control](services/claude-code-remote-control.md)

View file

@ -0,0 +1,170 @@
---
title: "Mastodon — Triaging Crowdfunding / Mention-Spam Accounts"
description: How to tell broadcast fundraising solicitation from genuine mentions, investigate the account and its origin instance with SQL + nodeinfo, and pick a proportionate moderation action.
tags:
- mastodon
- moderation
- abuse
- federation
- self-hosting
created: 2026-06-22
updated: 2026-06-22
---
# Mastodon — Triaging Crowdfunding / Mention-Spam Accounts
If you run a Mastodon instance, sooner or later you (or your users) start getting tagged by accounts you've never interacted with, posting donation appeals with a link and a wall of hashtags. Some are real people in desperate situations; some are recycled-link scams. Either way, when an account is **broadcasting a solicitation at you** rather than replying to you, it's a moderation question, not a conversation.
This article is the runbook for telling the two apart, investigating both the **account** and its **origin instance**, and choosing an action that's proportionate instead of nuking eight years of legit federation over two bad actors.
## TL;DR
- A mention is **broadcast spam**, not engagement, when it's a *standalone post* (not a reply) that *tags a large fixed list* of accounts and carries a *donation link*, usually from a *throwaway profile* on an *open-registration instance*.
- Investigate before acting: pull the account's age/stats/bio and check whether the post is a reply or a 40-way blast (SQL below). Profile the origin instance via its public `nodeinfo`.
- **Default action is an account-level block**, which also federates and removes their follow of you. Escalate to domain-limit / domain-block only when *one instance* produces *repeat offenders*.
- Keep a log so single incidents that are actually a pattern become visible.
## Signals that a mention is broadcast solicitation
Score it on how many of these hold:
| Signal | Why it matters |
|---|---|
| **Standalone post, not a reply** (`in_reply_to_account_id IS NULL`) but still tags you | They're broadcasting, not responding |
| **Tags a large fixed recipient list** (e.g. 40+) | Mass distribution; the same list reused across senders = coordination |
| **Donation link** in post or bio (`chuffed.org`, `gofundme`, `paypal.me`, `ko-fi`) | The payload |
| **Throwaway profile** — days old, few followers, follows you but you don't follow back | Disposable, baiting a profile view |
| **Mass-follow ratio** — following thousands / few hundred followers | Engagement farming |
| **"I am not a scammer" disclaimer** in bio | Known red-flag phrase |
| **Origin instance: open registration, no approval** | Easy throwaway-account farm |
> [!warning] Judgment, not a purity test
> Many of these accounts are real people. The goal is not to adjudicate need — it's to stop *broadcast solicitation aimed at you* and track the *source instances*. Prefer the lightest action that stops it.
## Investigate the account
Connect to the DB on the instance:
```bash
ssh <your-mastodon-host>
sudo -u postgres psql mastodon_production
```
**Profile + stats for a suspect** (age, post count, follower ratio, bio):
```sql
SELECT a.username||'@'||a.domain,
to_char(a.created_at,'YYYY-MM-DD') AS first_seen_locally,
st.statuses_count, st.followers_count, st.following_count,
left(regexp_replace(COALESCE(a.note,''),'<[^>]+>','','g'),200) AS bio
FROM accounts a LEFT JOIN account_stats st ON st.account_id=a.id
WHERE a.domain='<INSTANCE>' AND a.username='<HANDLE>';
```
**Is the mention a reply or a blast?** `standalone=t` with a high `num_tagged` is the tell:
```sql
SELECT a.username, to_char(s.created_at,'YYYY-MM-DD HH24:MI') AS posted,
s.in_reply_to_account_id IS NULL AS standalone,
(SELECT count(*) FROM mentions mm WHERE mm.status_id=s.id) AS num_tagged
FROM mentions m JOIN statuses s ON s.id=m.status_id
JOIN accounts a ON a.id=s.account_id
JOIN accounts me ON me.id=m.account_id AND me.username='<YOU>' AND me.domain IS NULL
WHERE a.username='<HANDLE>' AND a.domain='<INSTANCE>'
ORDER BY s.created_at DESC;
```
**All recent direct mentions of you** (sweep for the wider pattern):
```sql
SELECT to_char(n.created_at,'YYYY-MM-DD HH24:MI') AS when,
a.username||COALESCE('@'||a.domain,'@local') AS who,
COALESCE(s.uri,'') AS uri,
left(regexp_replace(COALESCE(s.text,''),'<[^>]+>','','g'),200) AS body
FROM notifications n
JOIN accounts recip ON recip.id=n.account_id AND recip.username='<YOU>' AND recip.domain IS NULL
JOIN accounts a ON a.id=n.from_account_id
LEFT JOIN mentions m ON m.id=n.activity_id AND n.activity_type='Mention'
LEFT JOIN statuses s ON s.id=m.status_id
WHERE n.type='mention' ORDER BY n.created_at DESC LIMIT 40;
```
## Profile the origin instance
Don't judge an instance by one bad account. Pull its public metadata — no auth needed:
```bash
# Software, version, user counts, registration policy
NI=$(curl -s https://<INSTANCE>/.well-known/nodeinfo | python3 -c 'import sys,json;print(json.load(sys.stdin)["links"][-1]["href"])')
curl -s "$NI" | python3 -m json.tool # software, openRegistrations, usage.users
# Title, contact/admin, rules, registration approval flag
curl -s https://<INSTANCE>/api/v2/instance | python3 -m json.tool
```
What to read off it:
- **`openRegistrations: true` + `approval_required: false`** → throwaway-account farm; expect more of the same.
- **`totalUsers` vs `activeMonth`** → a huge dormant base is typical of sign-up-and-leave farms.
- **Federation age on your side** — how long you've known the instance, how many of its accounts you cache. A long, broad relationship argues *against* a domain block.
- **The instance's own rules** — many ban "backlink accounts" / harassment, which the mass-tag fundraising violates. That makes **reporting to its admin a legitimate, in-policy path.**
```sql
-- What your instance already knows about the domain
SELECT (SELECT count(*) FROM accounts WHERE domain='<INSTANCE>') AS known_accounts,
(SELECT count(*) FROM statuses s JOIN accounts a ON a.id=s.account_id WHERE a.domain='<INSTANCE>') AS cached_statuses,
(SELECT to_char(min(created_at),'YYYY-MM-DD') FROM accounts WHERE domain='<INSTANCE>') AS first_seen,
(SELECT count(*) FROM domain_blocks WHERE domain='<INSTANCE>') AS is_domain_blocked;
```
## The escalation ladder
| Level | Action | Effect | When |
|---|---|---|---|
| 1 | **Mute** | You stop seeing them; silent | Borderline; you don't want to cut them off |
| 2 | **Block (account)** | Cuts mentions, removes their follow, federates to their instance | **Default first action** |
| 3 | **Report** to source admin | Forwards the offending posts to their moderators | Repeat or egregious; in-policy on most instances |
| 4 | **Domain-limit (silence)** | Their posts show only if you follow that account | One instance, multiple offenders |
| 5 | **Domain-block (suspend)** | Severs all known accounts + federation | Instance is predominantly abuse |
### Blocking from a user account (federates + removes follow)
There is no `tootctl accounts block`. Do it through the model's `BlockService` so it tears down the relationship and federates correctly:
```ruby
# run as the mastodon user:
# sudo -u mastodon bash -c 'cd /home/mastodon/live && RAILS_ENV=production bin/rails runner /tmp/block.rb'
me = Account.find_by(username: "<YOU>", domain: nil)
%w[Handle1 Handle2].each do |u|
t = Account.find_by(username: u, domain: "<INSTANCE>")
next puts("NOTFOUND #{u}") if t.nil?
BlockService.new.call(me, t)
puts "BLOCKED #{u} blocking=#{me.blocking?(t)} they_follow_me=#{t.following?(me)}"
end
```
`blocking=true` with `they_follow_me=false` confirms the block landed and the follow was severed.
### Instance-level actions
Domain-limit / domain-block live in the admin UI (**Moderation → Federation**) or via `tootctl`:
```bash
# Silence (limit) — posts hidden unless followed
RAILS_ENV=production bin/tootctl domains ... # or set severity=silence in the admin UI
# Suspend (block) the whole instance
RAILS_ENV=production bin/tootctl ... # admin UI "Add domain block" is the safe path
```
> [!tip] Reach for the lightest hammer
> A domain block is rarely the right first move against an established instance — you lose every legit account and years of federation to swat a couple of accounts. Block the accounts, report them to the source admin, and only escalate the *instance* when it demonstrates a sustained, multi-actor pattern.
## Keep a log
Track offenders and source instances over time so a "one-off" that's actually a campaign becomes visible, and so domain-level decisions are evidence-based. A simple table — date, account, instance, signals, action — plus an instance-watch table with each source's registration policy and offender count is enough.
## Related
- [Mastodon `--prune-profiles` Trap](mastodon-prune-profiles-trap.md)
- [Mastodon DB Maintenance](mastodon-db-maintenance.md)
- [Mastodon Federation](mastodon-federation.md)

View file

@ -44,6 +44,7 @@ updated: 2026-06-21T11:46
* [Mastodon Post-Install Hardening (Permissions + Account)](02-selfhosting/services/mastodon-post-install-hardening.md) * [Mastodon Post-Install Hardening (Permissions + Account)](02-selfhosting/services/mastodon-post-install-hardening.md)
* [Mastodon — The `--prune-profiles` Trap and How to Recover](02-selfhosting/services/mastodon-prune-profiles-trap.md) * [Mastodon — The `--prune-profiles` Trap and How to Recover](02-selfhosting/services/mastodon-prune-profiles-trap.md)
* [Mastodon on S3 — Silent Upload Failures (BucketOwnerEnforced/ACLs)](02-selfhosting/services/mastodon-s3-acl-upload-failures.md) * [Mastodon on S3 — Silent Upload Failures (BucketOwnerEnforced/ACLs)](02-selfhosting/services/mastodon-s3-acl-upload-failures.md)
* [Mastodon — Triaging Crowdfunding / Mention-Spam Accounts](02-selfhosting/services/mastodon-mention-spam-crowdfunding.md)
* [Ghost Email Configuration with Mailgun](02-selfhosting/services/ghost-smtp-mailgun-setup.md) * [Ghost Email Configuration with Mailgun](02-selfhosting/services/ghost-smtp-mailgun-setup.md)
* [Inbound Spam Filtering: spamass-milter + SpamAssassin Bayes](02-selfhosting/services/postfix-spamassassin-bayes-spam-filtering.md) * [Inbound Spam Filtering: spamass-milter + SpamAssassin Bayes](02-selfhosting/services/postfix-spamassassin-bayes-spam-filtering.md)
* [Claude Code Remote Control — Mobile Access to a Persistent Host Session](02-selfhosting/services/claude-code-remote-control.md) * [Claude Code Remote Control — Mobile Access to a Persistent Host Session](02-selfhosting/services/claude-code-remote-control.md)