MastodonとかのActivityPubサーバーで使えるスパム対策プロキシを作ったよ

Mastodonの個人インスタンスを立てて細々と活動していましたが、2024年2月に入って大量のスパムメッセージが一日に数十件送られてくるようになりました。最初は無視していましたが流石に鬱陶しくなり、Mastodonに投稿が届く前にブロックするためのリバースプロキシプログラムを書いたので、ご紹介も兼ねて仕組みの説明をしたいと思います。

## リポジトリ
コードはこちらで公開してあります。CC0パブリックドメインとして公開しており、Dockerを用いて簡単にセットアップすることができます。

[webscrubbing/simple-activitypub-spam-filter - GitHub](https://github.com/webscrubbing/simple-activitypub-spam-filter/tree/main)

使い方はREADME-JA.mdを参考にしてみてください。Docker ComposeやKubernetesでのサンプルもあります。

## Mastodonのスパム対策の現状

MastodonはTwitter(X)やFacebookとは違い、だれでもサーバーを開くことができます。サーバー間の通信方法は**ActivityPub**という仕組みで決まっており、これに対応しているソフトウェアであれば相互にフォローしたりメンションを送ったりすることができます。最近ではMastodonの他にもMisskeyがこの仕組みを実装していますね。

### 悪用される分散型の仕組み

しかし、この誰でもサーバーを開けるという特性上、サーバーの管理者によって管理状況がまちまちになってしまうという課題が存在します。例えば、脆弱性のあるバージョンを更新せずに放置してしまっていたり、アカウントの登録が簡単で悪用されやすい設定になっている等が挙げられます。

今回のスパムでは、上記のようなサーバーに数十件ほどのアカウントを作成し、Botを利用し大量の投稿を他のサーバーに投稿する手法を取っています。不特定多数のアカウントが不特定多数の管理が甘いサーバーに開設されてしまうため、特定のサーバー・ユーザー単位でしかブロックのできないMastodonではスパムを効率的に防ぐことができない点です。

### ソフトウェアでの対策

今回のスパムを起因として、各ActivityPubサーバー開発者により対策のための機能が実装されてきています。例えばMisskeyの場合は「禁止ワード」と称して特定のワードが含まれる投稿を無視する機能が実装されていますし、MastodonのフォークであるFediverd/MastodonでもReject Pattern機能が実装されました。

Misskeyの禁止ワードのプルリクエスト
https://github.com/misskey-dev/misskey/pull/13280

Fediverd/MastodonのReject Pattern機能
https://fedibird.com/@noellabo/111941451001175952

しかし、私の運用しているインスタンスはオリジナルのMastodonであり、そのような機能は現在のところ実装されていません。さらに、ソースコードに変更を加えることが難しい状態で運用がされているため、自身でパッチを適応することもままならない状況でした。

## サーバーの手前にプロキシを置いて解決しよう

そこで対策として作ったのが先頭で紹介したリポジトリです。これはMastodonを始めとするActivityPubサーバーの手前で動作し、リクエストを受け取ったらその内容に問題ないかを確認、問題がなければそのまま配送し、問題があればリクエストを止めるような動作をします。言語はGoを採用しました。

[webscrubbing/simple-activitypub-spam-filter - GitHub](https://github.com/webscrubbing/simple-activitypub-spam-filter/tree/main)

先述した通り、Mastodonを始めMisskeyでも、基本的な通信の形式はActivityPubで定められた形式として送られてきます。その形式に沿って投稿内容をパースすることで、プロキシサーバー内で内容を確認・精査することができるようになります。下記はその形式の一部をGoで解釈できるようにした構造体です。

```go
type Activity struct {
	Actor  string `json:"actor"`
	Object struct {
		Content string `json:"content"`
	} `json:"object"`
}
```

書いたコードも基本的には単純なもので、サーバーに投稿が送られてきた場合にその内容を確認しています。

```go
	http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
		if req.Body == nil {
			proxy.ServeHTTP(w, req)
			return
		}

		bodyBytes, err := io.ReadAll(req.Body)
		if err != nil {
			fmt.Println("Error reading body")
			os.Exit(1)
		}

		if req.Body.Close() != nil {
			fmt.Println("Error closing body")
			os.Exit(1)
		}

		req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

		var activity Activity
		if err := json.Unmarshal(bodyBytes, &activity); err != nil {
			proxy.ServeHTTP(w, req)
			return
		}

		if IsSpam(activity.Object.Content) {
			fmt.Println("Spam detected: ", activity.Object.Content)
			if whenDetectSpam == "block" {
				http.Error(w, "Spam detected", http.StatusForbidden)
			} else {
				proxy.ServeHTTP(w, req)
			}
			return
		}

		// Not spam
		proxy.ServeHTTP(w, req)
		return
	})
```


幸い、現状のスパムでは特定のURLを連投するだけなので、そのURLが本文中に含まれていないかを確認するだけでスパム判定が行えます。

```go
func IsSpam(content string) bool {
	blockWordEnv := os.Getenv("BLOCK_WORDS")
	blockWordList := strings.Split(blockWordEnv, ",")

	if blockWordEnv == "" || len(blockWordList) == 0 {
		fmt.Println("Environment variable BLOCK_WORDS is not set. Please set it to block words. ex: \"example_spam_word_1,example_spam_word_2\"")
		os.Exit(1)
	}

	for _, block := range blockWordList {
		if strings.Contains(content, block) {
			return true
		}
	}
	return false
}
```

この手法の良いところは、ActivityPub形式であればどのようなサーバーでも動作すること、サーバーのプログラムに手を加えなくても利用できることです。さらに、投稿をプログラムで判断できるので、将来的に攻撃手法が変わったときにも対応が比較的容易にできます。

## おわりに

今回のスパムはいたずら目的ながら、積極的にサーバーを守ろうとする管理者に対し、その管理者のメールアドレスを使って殺害・爆破予告をばらまいたりと悪質な行為も行われています。無用なトラブルを避けるためにも、私も今回は普段使いしているのとは別のアカウントでのコードの公開・記事の執筆をさせていただきました。

https://mastodon.hakurei.win/@relay/111947638150899464

このようなスパム・スパム対策はいたちごっこになりがちですが、すべてのActivityPubサーバーで使える共通化したプログラムを作ることで、個人のActivityPubサーバー管理人の負担がすこしでも軽減できればいいかなと考えております。