Mediawiki のスパムページと不正ユーザーを一括で削除するなど
Mediawikiに不正アクセスされて対処したのだが、なかなか面倒だったので、今後のためにメモ。
1.Mediawiki に何千人もの不正ユーザーが作成された。。。
FreeStyleWiki から Mediawikiに 移行作業をしている。自分がどこからでもメモを追加できるようにたてているWikiなので、
このあたりのページにあるように sysop にのみ edit権限を与えるつもりだったと思うのだが、
Mediawikiの勝手がわからなかったのか、以下の設定となっていた。
$wgGroupPermissions[‘*’][‘edit’] = false;
$wgGroupPermissions[‘user‘][‘edit’] = true;
このため、ユーザーの作成は自由 → ユーザーでログインすれば編集し放題な状態になっており、
以下のように辞書で組み合わせを作成したようなユーザーが1万件ほど作成されてしまっていた。
で、不正ユーザーでログインされて、スパムなページがこれまた大量に作成されてしまっていた。
2.対処する。
くそむかつくが、自らの手落ちなのであきらめて、以下の対応を順次とる。
- LocalSettingsで権限を厳格に【緊急】
- MySQLバックアップ【緊急】
- APIを用いてスパムページ削除【ぼちぼち】
- 不正ユーザーの削除【ぼちぼち】
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)する画面が表示される。
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"); } } } }