Mediawiki のスパムページと不正ユーザーを一括で削除するなど

Mediawikiに不正アクセスされて対処したのだが、なかなか面倒だったので、今後のためにメモ。

1.Mediawiki に何千人もの不正ユーザーが作成された。。。

FreeStyleWiki から Mediawikiに 移行作業をしている。自分がどこからでもメモを追加できるようにたてているWikiなので、

このあたりのページにあるように sysop にのみ edit権限を与えるつもりだったと思うのだが、

mediawiki_install

Mediawikiの勝手がわからなかったのか、以下の設定となっていた。

$wgGroupPermissions[‘*’][‘edit’] = false;

$wgGroupPermissions[‘user‘][‘edit’] = true;

このため、ユーザーの作成は自由 → ユーザーでログインすれば編集し放題な状態になっており、

以下のように辞書で組み合わせを作成したようなユーザーが1万件ほど作成されてしまっていた。

で、不正ユーザーでログインされて、スパムなページがこれまた大量に作成されてしまっていた。

mediawiki_spam_users

2.対処する。

くそむかつくが、自らの手落ちなのであきらめて、以下の対応を順次とる。

  1. LocalSettingsで権限を厳格に【緊急】
  2. MySQLバックアップ【緊急】
  3. APIを用いてスパムページ削除【ぼちぼち】
  4. 不正ユーザーの削除【ぼちぼち】

2.1  LocalSettings で権限を厳格に

以下のURLを参考に、

https://www.mediawiki.org/wiki/Manual:User_rights/ja

また、権限群と、利用者グループ権限一覧を確認しながら、グルLocalSettings.phpを編集し、グループ権限をはがしていく。

$wgGroupPermissions['sysop']['edit'] = true;
$wgGroupPermissions['sysop']['writeapi'] = true;

$wgGroupPermissions['*']['createpage'] = false;
$wgGroupPermissions['*']['edit'] = false;
$wgGroupPermissions['*']['createaccount'] = false;
$wgGroupPermissions['*']['writeapi'] = false;
$wgGroupPermissions['*']['editmywatchlist'] = false;
$wgGroupPermissions['*']['editmyoptions'] = false;
$wgGroupPermissions['*']['createtalk'] = false;
$wgGroupPermissions['*']['editmyprivateinfo'] = false;
$wgGroupPermissions['*']['viewmyprivateinfo'] = false;

$wgGroupPermissions['user']['edit'] = false;
$wgGroupPermissions['user']['move-categorypages'] = false;
$wgGroupPermissions['user']['purge'] = false;
$wgGroupPermissions['user']['upload'] = false;
$wgGroupPermissions['user']['movefile'] = false;
$wgGroupPermissions['user']['createpage'] = false;
$wgGroupPermissions['user']['editcontentmodel'] = false;
$wgGroupPermissions['user']['move'] = false;
$wgGroupPermissions['user']['move-subpages'] = false;
$wgGroupPermissions['user']['sendemail'] = false;
$wgGroupPermissions['user']['changetags'] = false;
$wgGroupPermissions['user']['reupload-shared'] = false;
$wgGroupPermissions['user']['move-rootuserpages'] = false;
$wgGroupPermissions['user']['reupload'] = false;
$wgGroupPermissions['user']['writeapi'] = false;
$wgGroupPermissions['user']['minoredit'] = false;
$wgGroupPermissions['user']['editmyusercss'] = false;
$wgGroupPermissions['user']['editmyuserjson'] = false;
$wgGroupPermissions['user']['editmyuserjs'] = false;
$wgGroupPermissions['user']['editmyuserjsredirect'] = false;
$wgGroupPermissions['user']['applychangetags'] = false;
$wgGroupPermissions['user']['createtalk'] = false;

2.2 MySQLのバックアップ

最悪復元できるように、MySQLのバックアップを取っておく。

# mysqldump --default-character-set=utf8 -uroot -p --all-databases > mysqlbackup
# tar -cvzf mysqlbackup.gz mysqlbackup

2.3 スパムページ削除と不正ユーザーの削除

ページの削除は、Mediawiki の APIを呼び出せばよいのだが、どうやら、Mediawikiではいったん作成されたユーザーは削除できないようだ。

なので、Extensionを導入して、削除したいユーザーを他のユーザーに統合、そのあとならユーザーを削除できるようだ。

https://www.mediawiki.org/wiki/Extension:UserMerge/ja

導入すると、削除するユーザーと統合先ユーザーを指定(削除する場合はチェックボックスをON)する画面が表示される。

mediawiki_merge_user

APIを呼び出してページ削除と、Seleniumでブラウザを操作してユーザーの統合と削除の操作を行うやっつけ(使い捨て)C#プログラムを作成する。

以下をNuGetする

  • Selenium.WebDriver
  • Selenium.WebDriver.ChromeDriver

あと、Chrome とバージョンにあった、chromedriver_win32 をインストール(ダウンロードしてPATHを通す)が必要。

動かすと、こんな感じになる。

不正なユーザーに作成されたページを削除する。

以下のように呼び出す。

作りこむのが面倒だったので、Mediawiki API呼び出しのパラメータ(from,to,limit)を適当に変えながら、Visual Studioから実行。

var api = new MediaWikiApi();

// 有効なユーザーのリスト
var validUser = new List() { 
    "Piroto",
    "Xxx"
};
api.DeleteInvalidUserCreatedPages(validUser);

同様に、不正なユーザーを削除する
var api = new MediaWikiApi();
// 有効な(削除しない)ユーザー
var validUser = new List() {
    "Piroto",
    "Xxx"
};

api.DeleteUsers("Xxx", validUser);

using System;
using System.Collections.Generic;
using System.Net.Http;
using Newtonsoft.Json;
using System.Text;
using System.IO;
using System.Net.Http.Headers;
using System.Web;
using System.Text.RegularExpressions;
using Fsw2Mw.Data;
using System.Linq;
using OpenQA.Selenium;
using OpenQA.Selenium.Edge;
using OpenQA.Selenium.Chrome;
using System.Threading;

namespace Fsw2Mw
{
    class MediaWikiApi
    {
        static readonly string API_URL = @"https://yourmediawiki/api.php";
        static readonly string USER_NAME = "yourid";
        static readonly string PASSWORD = "yourpassword";

        static readonly HttpClient client = new HttpClient();

        public string Ready()
        {
            var loginToken = GetLoginToken();
            LoginRequest(loginToken);
            var csrfToken = CsrfToken();
            return csrfToken;
        }

        /// <summary>
        /// 指定されたユーザーが編集にかかわっていないページを削除する
        /// https://www.mediawiki.org/wiki/API:Allpages
        /// https://www.mediawiki.org/wiki/API:Delete
        /// https://www.mediawiki.org/wiki/API:Properties
        /// </summary>
        public void DeleteInvalidUserCreatedPages(List<string> pValidUsers)
        {
            var csrfToken = Ready();

            var validUsers = pValidUsers.Select((s) => s.ToLower());

            var from = "N";
            var to = from;
            var limit = 5000;

            string path = $@"c:\work\wiki_delete_info_from_{from}_to_{to}_limit_{limit}.csv";

            using ( var writer = new StreamWriter(path, false, System.Text.Encoding.GetEncoding("shift_jis")))
            {
                writer.WriteLine("del,result,pageid,title,users");
                var response = client.GetAsync($"{API_URL}?action=query&format=json&list=allpages&apfrom={from}&apTo={to}&aplimit={limit}").Result;
                if (response.StatusCode == System.Net.HttpStatusCode.OK)
                {
                    var json = JsonConvert.DeserializeObject<dynamic>(response.Content.ReadAsStringAsync().Result);
                    var pages = json["query"]["allpages"];
                    foreach(var page in pages)
                    {
                        var line = new StringBuilder();

                        Console.WriteLine($"{page.pageid}\t{page.title}");
                        line.Append($"\"{page.pageid}\",\"{page.title}\"");

                        var title = page.title;
                        var response2 = client.GetAsync($"{API_URL}?action=query&format=json&prop=contributors&titles={title}").Result;
                        var json2 = JsonConvert.DeserializeObject<dynamic>(response2.Content.ReadAsStringAsync().Result);

                        bool isDelete = true;
                        try
                        {
                            foreach(var delpage in json2.query.pages)
                            {
                                foreach(var editors in delpage)
                                {
                                    foreach(var user in editors.contributors)
                                    {
                                        string username = user.name;
                                        if (isDelete && validUsers.Contains(username.ToLower()))
                                        {
                                            isDelete = false;
                                        }
                                        line.Append($",{username}");
                                    }
                                }
                            }
                        } catch(Exception)
                        {
                            isDelete = false;
                        }

                        var del = ((isDelete) ? "DEL" : "");
                        var delResult = "";
                        if (isDelete)
                        {
                            var param = new Dictionary<string, string>()
                            {
                                {"pageid", $"{page.pageid}" },
                                {"action","delete" },
                                {"format","json" },
                                {"token", csrfToken },
                            };
                            var content = new FormUrlEncodedContent(param);
                            var delRes = client.PostAsync($"{API_URL}", content).Result;
                            if (delRes.StatusCode == System.Net.HttpStatusCode.OK)
                            {
                                delResult = "DELETED";
                                try
                                {
                                    var delContent = delRes.Content.ReadAsStringAsync().Result;
                                    Console.WriteLine(delContent);
                                }
                                catch (Exception) { }
                            }
                        }
                        writer.WriteLine($"{del},{delResult}," + line.ToString());
                    }
                }
            }
        }

        /// <summary>
        /// 
        /// https://www.mediawiki.org/wiki/Extension:UserMerge/ja
        /// </summary>
        /// <param name="mergeUser"></param>
        internal void DeleteUsers(string mergeUser, List<string> pValidUsers)
        {

            var deleteUsers = GetAllUsers(pValidUsers,500);

            
            var driver = new ChromeDriver();

            // ログインページ
            driver.Navigate().GoToUrl("https://yourmediawiki/index.php?title=%E7%89%B9%E5%88%A5:%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3&returnto=%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8");
            var loginUser = driver.FindElementByName("wpName");
            loginUser.SendKeys(USER_NAME);
            var passwd = driver.FindElementByName("wpPassword");
            passwd.SendKeys(PASSWORD);
            var loginButton = driver.FindElementByName("wploginattempt");
            loginButton.Click();

            Thread.Sleep(2000);

            foreach (var deleteUser in deleteUsers)
            {
                // ユーザー統合と削除
                driver.Navigate().GoToUrl("https://yourmediawiki/index.php/%E7%89%B9%E5%88%A5:%E5%88%A9%E7%94%A8%E8%80%85%E3%81%AE%E7%B5%B1%E5%90%88");
                var spamUserText = driver.FindElementByName("wpolduser");
                spamUserText.SendKeys(deleteUser);
                var mergeUserText = driver.FindElementByName("wpnewuser");
                mergeUserText.SendKeys(mergeUser);
                var checkDelete = driver.FindElementByName("wpdelete");
                checkDelete.Click();

                mergeUserText.SendKeys(Keys.Enter);

                Thread.Sleep(2000);
            }
        }

        /// <summary>
        /// https://www.mediawiki.org/wiki/API:Allusers
        /// </summary>
        public List<string> GetAllUsers(List<string> pExcludeUsers, int limit)
        {
            var result = new List<string>();
            var excludeUsers = pExcludeUsers.Select((s) => s.ToLower());

            var response = client.GetAsync($"{API_URL}?action=query&format=json&list=allusers&aulimit={limit}").Result;
            if (response.StatusCode == System.Net.HttpStatusCode.OK)
            {
                var json = JsonConvert.DeserializeObject<dynamic>(response.Content.ReadAsStringAsync().Result);
                foreach(var user in json.query.allusers)
                {
                    var userName = $"{user.name}";
                    // 正常ユーザーは除外
                    if (!excludeUsers.Contains(userName.ToLower()))
                    {
                        result.Add(userName);
                    }
                }
            }
            return result;
        }

        /// <summary>
        /// 003.CSRFトークンの取得
        /// </summary>
        private string CsrfToken()
        {
            var param = new Dictionary<string, string>()
            {
                { "action", "query" },
                {"meta","tokens" },
                {"format","json" },
            };

            var response = client.GetAsync($"{API_URL}?{new FormUrlEncodedContent(param).ReadAsStringAsync().Result}").Result;
            if (response.StatusCode == System.Net.HttpStatusCode.OK)
            {
                var json = JsonConvert.DeserializeObject<dynamic>(response.Content.ReadAsStringAsync().Result);
                var crsftoken = $"{json.query.tokens.csrftoken}";
                Console.WriteLine($"CRSF TOKEN={crsftoken}");
                return crsftoken;
            }
            else
            {
                throw new Exception("GetLoginToke Error");
            }
        }

        /// <summary>
        /// 002.ログイン
        /// </summary>
        /// <param name="loginToken"></param>
        public void LoginRequest(string loginToken)
        {
            var param = new Dictionary<string, string>()
            {
                {"action", "login" },
                {"lgname", USER_NAME},
                {"lgpassword",PASSWORD },
                {"lgtoken",loginToken },
                {"format","json" },
            };

            var response = client.PostAsync(API_URL, new FormUrlEncodedContent(param)).Result;
            if (response.StatusCode == System.Net.HttpStatusCode.OK)
            {
                var json = JsonConvert.DeserializeObject<dynamic>(response.Content.ReadAsStringAsync().Result);
                Console.WriteLine(json);
            }
            else
            {
                throw new Exception("LoginRequest Error");
            }
        }

        /// <summary>
        /// 001.ログイントークンの取得
        /// </summary>
        public string GetLoginToken()
        {
            var param = new Dictionary<string, string>()
            {
                {"action", "query" },
                {"meta","tokens" },
                {"type","login" },
                {"format","json" },
            };

            var response = client.GetAsync($"{API_URL}?{new FormUrlEncodedContent(param).ReadAsStringAsync().Result}").Result;
            if (response.StatusCode == System.Net.HttpStatusCode.OK)
            {
                var json = JsonConvert.DeserializeObject<dynamic>(response.Content.ReadAsStringAsync().Result);
                var logintoken = $"{json.query.tokens.logintoken}";
                Console.WriteLine($"LOGIN TOKEN={logintoken}");
                return logintoken;
            } 
            else
            {
                throw new Exception("GetLoginToke Error");
            }
        }
    }
}

Follow me!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です