From 2def4c6f30653c144e25b14e9803fc89a2881564 Mon Sep 17 00:00:00 2001 From: MajorLinux Date: Mon, 22 Jun 2026 13:49:35 -0400 Subject: [PATCH] wiki: add Mastodon crowdfunding/mention-spam triage runbook Runbook for telling broadcast fundraising solicitation from genuine mentions: signal checklist, SQL to investigate the account and its origin instance via nodeinfo, BlockService snippet, and a proportionate escalation ladder (mute -> block -> report -> domain-limit -> domain-block). Registered in SUMMARY.md and the self-hosting section index. --- 02-selfhosting/index.md | 1 + .../mastodon-mention-spam-crowdfunding.md | 170 ++++++++++++++++++ SUMMARY.md | 1 + 3 files changed, 172 insertions(+) create mode 100644 02-selfhosting/services/mastodon-mention-spam-crowdfunding.md diff --git a/02-selfhosting/index.md b/02-selfhosting/index.md index d2467ab..e418de5 100644 --- a/02-selfhosting/index.md +++ b/02-selfhosting/index.md @@ -38,6 +38,7 @@ Guides for running your own services at home, including Docker, reverse proxies, - [Mastodon Federation](services/mastodon-federation.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 — Triaging Crowdfunding / Mention-Spam Accounts](services/mastodon-mention-spam-crowdfunding.md) - [Ghost SMTP via Mailgun](services/ghost-smtp-mailgun-setup.md) - [Updating n8n Docker](services/updating-n8n-docker.md) - [Claude Code Remote Control](services/claude-code-remote-control.md) diff --git a/02-selfhosting/services/mastodon-mention-spam-crowdfunding.md b/02-selfhosting/services/mastodon-mention-spam-crowdfunding.md new file mode 100644 index 0000000..42bd794 --- /dev/null +++ b/02-selfhosting/services/mastodon-mention-spam-crowdfunding.md @@ -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 +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='' AND a.username=''; +``` + +**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='' AND me.domain IS NULL +WHERE a.username='' AND a.domain='' +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='' 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:///.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:///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='') AS known_accounts, + (SELECT count(*) FROM statuses s JOIN accounts a ON a.id=s.account_id WHERE a.domain='') AS cached_statuses, + (SELECT to_char(min(created_at),'YYYY-MM-DD') FROM accounts WHERE domain='') AS first_seen, + (SELECT count(*) FROM domain_blocks WHERE domain='') 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: "", domain: nil) +%w[Handle1 Handle2].each do |u| + t = Account.find_by(username: u, domain: "") + 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) diff --git a/SUMMARY.md b/SUMMARY.md index 9c9ce90..b3cdcec 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -44,6 +44,7 @@ updated: 2026-06-21T11:46 * [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 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) * [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)