| name | wikimedia-security-and-privacy |
| description | Build tools that respect Wikimedia user privacy and security — data minimization, suppressed/deleted revision handling, deanonymization risks, AbuseFilter and block awareness, XSS prevention in gadgets/apps, and data retention policies for Toolforge tools |
| depends_on | ["wikimedia-api-access","wikimedia-auth-oauth","wikimedia-toolforge"] |
| license | MIT |
| compatibility | opencode |
| last_verified | "2026-06-11T00:00:00.000Z" |
| skill_discovery_hints | [{"keywords":["privacy","security","data minimization","PII","deanonymization"]},{"keywords":["suppressed revision","deleted revision","AbuseFilter","block","XSS"]},{"keywords":["data retention","Toolforge privacy","user data","permission check"]}] |
⚠️ Prerequisites: This skill assumes you can authenticate API clients and
check user rights. See wikimedia-auth-oauth
for OAuth flows, bot passwords, CSRF tokens, and permission checking.
See wikimedia-api-access for User-Agent
headers and rate limiting.
Overview
Every Wikimedia tool that processes editor data, makes edits, or serves a web
interface has privacy and security responsibilities. This skill covers the
Wikimedia-specific concerns that general web security guides miss.
This skill is organized into two main sections:
| Section | Concern | For |
|---|
| Privacy (user-facing) | What data you collect, keep, and expose | Tools that display or log editor info |
| Security (internal-facing) | How to prevent exploits and respect wiki protections | Tools that edit, patrol, or run gadgets |
Reference: Quick Decision Flow
You're building a tool that...
│
├─ Collects ANY data about users (usernames, IPs, timestamps)
│ → Read SOP: Data Minimization
│ → Read SOP: Data Retention
│ → Read SOP: Public Logs and Leaks
│
├─ Displays or ranks editor activity
│ → Read SOP: Deanonymization Risks
│ → Read SOP: Suppressed/Deleted Revisions
│
├─ Makes edits or interacts with wiki content
│ → Read SOP: Block Awareness
│ → Read SOP: AbuseFilter Awareness
│ → Read SOP: Permission-Aware Design
│ → See also: wikimedia-auth-oauth (CSRF tokens, credential storage)
│
├─ Loads in a user's browser (gadget, userscript, Toolforge app)
│ → Read SOP: XSS Prevention
│
└─ You're unsure what's allowed
→ Read the WMF Privacy Policy and TOU (see references)
SOP: Data Minimization
Rule: Collect the Minimum Data Needed
When using the Action API or OAuth profile endpoint, request only the fields
you actually need.
profile = client.get_profile(token)
user_ip = profile.get("ip")
def get_username_only(token: str) -> str:
"""Get just the username. No IP, no email, no real name."""
profile = client.get_profile(token)
return profile.get("username")
OAuth Grant Scopes
When registering an OAuth consumer, request the minimum grants:
Action API: Request Only the Fields You Need
params = {
"action": "query",
"list": "allusers",
"aulimit": "max",
}
params = {
"action": "query",
"list": "allusers",
"aulimit": "max",
"auprop": "editcount|registration",
}
Sensitive fields to avoid unless essential:
| Property | Contains | Risk |
|---|
email | User's email address | PII — never expose publicly |
realname | Real name (if provided) | PII — avoid unless required |
blockedby | Admin who placed a block | Can identify admins in private actions |
blockreason | Reason for a block | May contain private information |
userid | Internal user ID | Can be used to track users across renames |
Never Log Raw API Responses
logger.debug(f"API response: {api_response}")
def safe_log_response(response: dict) -> None:
"""Log API response without user PII."""
safe = {k: v for k, v in response.items()
if k not in ("email", "realname", "blockreason", "ip")}
logger.debug(f"API response: {safe}")
CDN and Third-Party Scripts
Loading JavaScript, CSS, or fonts from external CDNs (cdnjs.cloudflare.com,
unpkg.com, jsdelivr.net, Google Fonts) leaks users' IP addresses, referrer
headers, and user-agent strings to the CDN provider — a form of data
egress that violates the data minimization principle.
For Toolforge tools, Wikimedia's privacy policy requires using the
internal cdnjs mirror instead:
https://tools-static.wmflabs.org/cdnjs/ajax/libs/<library>/<version>/<file>
See the wikimedia-toolforge skill's
SOP 9: Frontend Assets (Privacy-Preserving CDN) for the full policy,
search commands, and troubleshooting.
For any Wikimedia tool, prefer self-hosting static assets or using the
WMF mirror over third-party CDNs.
SOP: Deanonymization Risks
What Not To Do
Some tools can inadvertently enable stalking or harassment. Avoid these patterns:
def get_user_edit_times(username: str) -> dict:
"""Return a user's edit times. Can reveal time zone / sleep schedule."""
pass
def find_same_user_across_wikis(pattern: str) -> list:
"""Find accounts with similar edit patterns on different wikis.
This can deanonymize users who maintain separate accounts."""
pass
def show_user_location(username: str) -> str:
"""Show city/country derived from IP. Enables harassment."""
pass
Allowed vs. Not Allowed
| Action | Privacy Risk | Verdict |
|---|
| Show a user's edit count | Low — already public on user pages | ✅ OK |
| Show a user's recent contributions list | Low — already public | ✅ OK |
| Aggregate stats ("top 100 editors by edits") | Low — group, not individual | ✅ OK |
| Show a user's edit times by hour of day | Medium — reveals time zone / sleep schedule | ❌ Avoid |
| Cross-reference edits across unrelated wikis | High — deanonymizes | ❌ Never |
| Display IP geolocation derived from edits | High — enables stalking | ❌ Never |
| Build a "users who edit X also edit Y" graph | Medium — can reveal relationships | ❌ Avoid |
| Export a list of all editors of a page | Low — already in page history | ✅ OK if attributed to the page |
Safe Aggregation Principle
When building statistics or leaderboards, aggregate enough that individuals
cannot be identified:
def edits_by_month(edits: list) -> dict:
"""Count edits per month. Single-day granularity could identify patterns."""
result = {}
for edit in edits:
month = edit["timestamp"][:7]
result[month] = result.get(month, 0) + 1
return result
def edits_by_hour(edits: list) -> dict:
for edit in edits:
hour = edit["timestamp"][11:13]
...
SOP: Public Logs and Leaks
Toolforge Web Service Logs
Toolforge web services automatically log requests. These logs contain
IP addresses and User-Agent strings:
2026-06-11 12:34:56 INFO "GET /api/search" 200 -
from 192.0.2.1 (Mozilla/5.0 ...)
Rules:
- Do not expose
.toolforge-log files publicly — they contain IPs.
These are stored in /data/project/<tool>/logs/ and are already
access-restricted by Toolforge.
- Do not echo raw request data in error messages or responses.
- Do not store IPs in your application database — use
Toolforge's existing logs if you need debugging info.
- Do not pass log data to external services (Sentry, Logstash, etc.)
unless you strip IPs and User-Agents first.
Stripping IPs from Log Output
import re
def strip_ip_from_log(line: str) -> str:
"""Remove IP addresses from log lines before storage or display."""
line = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP]', line)
line = re.sub(r'\b[0-9a-fA-F:]{2,40}\b', '[IP]', line)
return line
@app.after_request
def sanitize_log(response):
"""Strip IPs from log messages before they reach stdout."""
logging.getLogger().handlers[0].filters.append(
lambda record: setattr(record, 'msg', strip_ip_from_log(record.msg)) or True
)
return response
Never Return Sensitive Data in API Responses
@app.route("/api/profile")
def api_profile():
user_data = get_user_data(user_id)
return jsonify(user_data)
@app.route("/api/profile")
def api_profile():
user_data = get_user_data(user_id)
return jsonify({
"username": user_data["username"],
"edit_count": user_data["editcount"],
"registration_date": user_data["registration"],
})
SOP: Data Retention
Why It Matters
Wikimedia's Data Retention Guidelines
state that data collected by tools should be retained only as long as necessary.
If your tool stores user data indefinitely, it becomes a privacy liability.
Retention Rules
| Data Type | Max Retention | Notes |
|---|
| OAuth access/refresh tokens | Until revoked by user | Must be deletable on request |
| Cached API responses with user data | 24 hours | Cache with Cache-Control: private |
| User IP addresses | Do not store | Use Toolforge logs for debugging |
| Aggregate statistics (no PII) | Indefinite | Safe if truly anonymous |
| User-identifiable logs | 90 days | After that, rotate or anonymize |
Implementation Patterns
import time
from datetime import datetime, timedelta
class UserDataCache:
"""Cache that auto-expires user data after TTL."""
def __init__(self, ttl_hours: int = 24):
self._cache = {}
self._ttl = timedelta(hours=ttl_hours)
def set(self, key: str, value: dict) -> None:
"""Store data with an expiration timestamp."""
self._cache[key] = {
"data": value,
"expires_at": datetime.utcnow() + self._ttl,
}
def get(self, key: str) -> dict | None:
"""Get data if not expired."""
entry = self._cache.get(key)
if entry is None:
return None
if datetime.utcnow() > entry["expires_at"]:
del self._cache[key]
return None
return entry["data"]
def delete_user_data(self, key: str) -> None:
"""Delete a specific user's data on request."""
self._cache.pop(key, None)
def clear_expired(self) -> int:
"""Remove all expired entries. Returns count removed."""
now = datetime.utcnow()
expired = [k for k, v in self._cache.items()
if now > v["expires_at"]]
for k in expired:
del self._cache[k]
return len(expired)
class AggregateStats:
"""Store only counts, never individual user data."""
def __init__(self):
self.stats = {"total_users": 0, "total_edits": 0}
def record_edit(self, username: str) -> None:
"""Increment counts without recording the username."""
self.stats["total_users"] += 1
self.stats["total_edits"] += 1
User Data Deletion Policy
Every tool that stores user data should provide a deletion mechanism:
@app.route("/api/delete-my-data", methods=["POST"])
@login_required
def delete_user_data():
"""Delete all data associated with the authenticated user."""
username = session.get("profile", {}).get("username")
if username:
cache.delete_user_data(username)
db.execute("DELETE FROM tool_data WHERE username = %s", (username,))
log_action(f"Deleted data for user {username}")
return jsonify({"status": "deleted"})
SOP: XSS Prevention (Gadgets and Toolforge Apps)
Context: Why XSS Matters on Wikimedia
Gadgets and user scripts run with the user's full session. An XSS vulnerability
in a popular gadget can compromise every user who has it enabled. Toolforge apps
that embed user-generated wikitext are also at risk.
Never Trust Wikitext or User Input
html = f"<div>{page_content}</div>"
import html
def render_safe_wikitext(wikitext: str) -> str:
"""Escape HTML entities in wikitext before rendering."""
return html.escape(wikitext)
Safe DOM Manipulation in Gadgets
element.innerHTML = '<div>' + userControlled + '</div>';
const div = document.createElement('div');
div.textContent = userControlled;
element.appendChild(div);
element.textContent = '';
const safe = document.createElement('div');
safe.textContent = userControlled;
element.appendChild(safe);
Never Use eval() or Similar
const result = eval('(' + apiResponse + ')');
$.globalEval(someUserControlledString);
const data = JSON.parse(apiResponse);
Content Security Policy for Toolforge Apps
from flask import Flask
from flask_talisman import Talisman
app = Flask(__name__)
Talisman(app, content_security_policy={
'default-src': "'self'",
'script-src': ["'self'", "https://tools-static.wmflabs.org"],
'style-src': ["'self'", "https://tools-static.wmflabs.org"],
'img-src': "'self' data:",
})
Safe MediaWiki API Responses in Gadgets
const userBio = data.query.pages[pageId].extract;
$('#bio').html(userBio);
const userBio = data.query.pages[pageId].extract;
$('#bio').text(userBio);
SOP: AbuseFilter and Block Awareness
Why Check Before Acting
Tools that make edits or perform actions must check whether the user is
blocked and whether the action would trigger an AbuseFilter. Failure to do so
results in confusing errors and wasted API calls.
Check Before Editing
def can_user_edit(access_token: str, wiki: str = "en.wikipedia.org") -> dict:
"""Check if the authenticated user can edit on the target wiki.
Returns:
{"can_edit": True/False, "reason": "..."}
"""
data = api_call(access_token, {
"action": "query",
"meta": "userinfo",
"uiprop": "blockinfo|rights",
}, wiki)
userinfo = data["query"]["userinfo"]
if userinfo.get("blocked"):
return {
"can_edit": False,
"reason": f"User is blocked: {userinfo.get('blockreason', 'No reason given')}"
}
if "edit" not in userinfo.get("rights", []):
return {
"can_edit": False,
"reason": "User does not have the 'edit' right"
}
return {"can_edit": True, "reason": ""}
def edit_page_safe(access_token, title, text, summary):
"""Edit a page, but only if the user can edit."""
check = can_user_edit(access_token)
if not check["can_edit"]:
logger.warning(f"Edit blocked: {check['reason']}")
return {"error": check["reason"]}
Handling AbuseFilter Warnings
When an edit triggers an AbuseFilter, the API returns an abuseFilter warning:
def edit_page_with_abuse_filter_handling(
access_token: str, title: str, text: str, summary: str
) -> dict:
"""Edit a page and handle AbuseFilter warnings gracefully."""
result = edit_page(access_token, title, text, summary)
edit_result = result.get("edit", {})
if edit_result.get("result") == "Failure":
abuse_info = edit_result.get("abuseFilter", {})
if abuse_info:
code = abuse_info.get("code", "unknown")
description = abuse_info.get("description", "No description")
return {
"success": False,
"reason": f"AbuseFilter triggered ({code}): {description}",
"abuse_filter_code": code,
"abuse_filter_description": description,
}
return result
Check Page Protection Before Editing
def check_page_protection(
access_token: str, title: str, wiki: str = "en.wikipedia.org"
) -> dict:
"""Check if a page is protected and whether the user can edit it."""
data = api_call(access_token, {
"action": "query",
"titles": title,
"prop": "info",
"inprop": "protection",
}, wiki)
pages = data["query"]["pages"]
page_id = list(pages.keys())[0]
page = pages[page_id]
protections = page.get("protection", [])
if not protections:
return {"protected": False, "reason": ""}
userinfo = api_call(access_token, {
"action": "query",
"meta": "userinfo",
"uiprop": "rights",
}, wiki)
user_rights = userinfo["query"]["userinfo"].get("rights", [])
for protection in protections:
level = protection.get("level", "")
if level == "autoconfirmed" and "autoconfirmed" not in user_rights:
return {"protected": True, "reason": "Page is semi-protected"}
if level == "extendedconfirmed" and "extendedconfirmed" not in user_rights:
return {"protected": True, "reason": "Page is extended-confirmed protected"}
if level == "sysop" and "delete" not in user_rights:
return {"protected": True, "reason": "Page is fully protected"}
return {"protected": False, "reason": ""}
SOP: Suppressed and Deleted Revisions
What They Are
- Deleted revisions (
action=history&hide=1) — Hidden from public view
but still visible to admins.
- Suppressed/oversighted revisions — Completely hidden from all
non-oversight users. Used for private information (addresses, phone
numbers, threats).
How the API Behaves
data = api_call(access_token, {
"action": "query",
"list": "recentchanges",
"rcprop": "title|timestamp|user",
})
data = api_call(access_token, {
"action": "query",
"prop": "deletedrevisions",
"titles": "Some Page",
})
Rules for Tool Developers
-
Never attempt to circumvent visibility. If an edit is deleted or
suppressed, respect that. Do not try to find it through other means
(archives, web caches, external mirrors).
-
Never display deleted revision content even if you have the right
to view it. If your tool runs with admin credentials, check visibility
flags before displaying content:
def safe_display_revision(rev: dict) -> dict | None:
"""Return revision data only if it's safe to display.
Revision visibility flags:
- suppressed=1: Content should not be shown to anyone
- deleted=1: Content deleted from public view
"""
if rev.get("suppressed") or rev.get("deleted"):
return None
return {
"revid": rev["revid"],
"content": rev.get("*"),
"user": rev.get("user"),
}
-
Do not cache or archive deleted/suppressed content. If you
accidentally retrieve it, discard it immediately.
-
Do not reveal that suppressed content exists. Don't show
"[REDACTED]" or similar indicators — that itself leaks information.
Simply omit the entry.
SOP: Permission-Aware Design
The Principle
Every API action your tool performs should be preceded by a permission check.
Don't assume a user has rights — verify them and handle denial gracefully.
Check Rights Before Acting
REQUIRED_RIGHTS = {
"edit": "edit",
"delete": "delete",
"protect": "protect",
"block": "block",
"rollback": "rollback",
"patrol": "patrol",
"upload": "upload",
"move": "move",
}
def check_rights(
access_token: str, required_actions: list[str],
wiki: str = "en.wikipedia.org"
) -> dict:
"""Check that the user has all required rights.
Args:
access_token: OAuth Bearer token
required_actions: List of actions the user wants to perform
wiki: Target wiki
Returns:
{"has_all": True/False, "missing": [...], "user_rights": [...]}
"""
data = api_call(access_token, {
"action": "query",
"meta": "userinfo",
"uiprop": "rights",
}, wiki)
user_rights = data["query"]["userinfo"].get("rights", [])
missing = []
for action in required_actions:
right = REQUIRED_RIGHTS.get(action)
if right and right not in user_rights:
missing.append(action)
return {
"has_all": len(missing) == 0,
"missing": missing,
"user_rights": user_rights,
}
def handle_batch_delete(access_token: str, pages: list[str]):
"""Delete multiple pages. Check permissions first."""
check = check_rights(access_token, ["delete"])
if not check["has_all"]:
return {
"error": "Cannot delete: missing required rights",
"missing": check["missing"],
}
Graceful Degradation
When a user lacks a right, show a clear message and offer alternatives
rather than a hard failure:
def perform_action(action: str, rights_check: dict) -> str | None:
"""Return a user-facing error message if the action can't be performed."""
if not rights_check["has_all"]:
missing = rights_check["missing"]
if "delete" in missing:
return "You don't have permission to delete. Would you like to nominate the page for deletion instead?"
if "protect" in missing:
return "You don't have permission to protect. You can request protection at WP:RFPP."
if "block" in missing:
return "You don't have permission to block. Report the user at WP:AIV."
return f"Missing rights: {', '.join(missing)}"
return None
Guardrails
❌ Never Store IP Addresses
Toolforge already logs IPs. Your application should not store them too.
If you need request tracking, use a session ID instead.
❌ Never Expose Suppressed Content
If you have admin access and retrieve deleted/suppressed revisions, do not
display, cache, or forward them. Discard them immediately.
❌ Never Build User Profiles
Do not aggregate edit data to build behavior profiles, infer demographics,
or predict personal information about editors.
❌ Never Deanonymize
Do not cross-correlate accounts across wikis, expose edit patterns that
reveal time zones or schedules, or derive location data from IPs.
❌ Never Log Sensitive API Responses
Strip email, realname, blockreason, and ip fields from log output.
❌ Never Use eval() or Dynamic HTML in Gadgets
All user-controlled content must be escaped. Use textContent, not innerHTML.
❌ Never Assume Permission
Check user rights before every action. A user may lose rights between
when they logged in and when they click a button.
❌ Never Store Data Indefinitely
Implement automatic data expiration. Provide a user-facing deletion mechanism.
Tooling
🔧 Scripts
🐍 Python Assets
| Asset | Purpose | Usage |
|---|
assets/safe_editor.py | Importable module: safe editing with block/protection/rights checks, AbuseFilter handling, and suppressed revision awareness | from safe_editor import SafeEditor |
assets/privacy_cache.py | Importable module: auto-expiring cache that strips PII from logged data | from privacy_cache import PrivacyCache |
📚 Reference Docs