Module:Sensitive IP addresses/API: Difference between revisions

Content deleted Content added
m use "test" rather than "ips", as there could be subnets as well
m Protected "Module:Sensitive IP addresses/API": High-risk Lua module: used in the MediaWiki interface, e.g. MediaWiki:Blockiptext via Template:Sensitive IP addresses ([Edit=Require administrator access] (indefinite...
 
(20 intermediate revisions by 2 users not shown)
Line 2:
 
-- Load modules
local libraryUtilmIP = require('libraryUtilModule:IP')
local checkTypeIPAddress = libraryUtilmIP.checkTypeIPAddress
local Subnet = mIP.Subnet
local IPv4Collection = mIP.IPv4Collection
local IPv6Collection = mIP.IPv6Collection
 
-- Lazily load the jf-JSON module
local sensitivityReasons = {
local JSON
 
-------------------------------------------------------------------------------
-- Helper functions
-------------------------------------------------------------------------------
 
local function deepCopy(val)
-- Make a deep copy of a value, but don't worry about self-references or
-- metatables as mw.clone does. If a table in val has a self-reference,
-- you will get an infinite loop, so don't do that.
if type(val) == 'table' then
local ret = {}
for k, v in pairs(val) do
ret[k] = deepCopy(v)
end
return ret
else
return val
end
end
 
local function deepCopyInto(source, dest)
-- Do a deep copy of a source table into a destination table, ignoring
-- self-references and metatables. If a table in source has a self-reference
-- you will get an infinite loop.
for k, v in pairs(source) do
if type(v) == 'table' then
dest[k] = {}
deepCopyInto(v, dest[k])
else
dest[k] = v
end
end
end
 
local function removeDuplicates(t)
-- Return a copy of an array with duplicate values removed.
local keys, ret = {}, {}
for i, v in ipairs(t) do
if not keys[v] then
table.insert(ret, v)
keys[v] = true
end
end
return ret
end
 
-------------------------------------------------------------------------------
-- SensitiveEntity class
-- A country or organization for which blocks must be handled with care.
-- Media organizations may inspect block messages for IP addresses and ranges
-- belonging to these entities and those messages may end up in the press.
-------------------------------------------------------------------------------
 
local SensitiveEntity = {}
SensitiveEntity.__index = SensitiveEntity
 
SensitiveEntity.reasons = {
-- The reasons that an entity may be sensitive. Used to verify data in
-- Module:Sensitive IP addresses/list.
political = true,
technical = true,
}
 
do
-- Plan of attack:
-- Private methods
-- * Load the data from Module:Sensitive IP addresses/list via a formatting module
local function addRanges(self, key, collectionConstructor, ranges)
-- at Module:Sensitive IP addresses/data
if ranges and ranges[1] then
-- * This module will do preprocessing that must be done for every query
self[key] = collectionConstructor()
-- * Make an API so that other modules can query this module for data about
for i, range in ipairs(ranges) do
-- sensitive IPs and ranges.
self[key]:addSubnet(Subnet.new(range))
-- * Export query results as both a Lua table and as JSON
end
-- * Use this API to create a table to be used in
end
-- [[Template:Sensitive IP addresses]].
end
 
-- Constructor
function SensitiveEntity.new(data)
local self = setmetatable({}, SensitiveEntity)
 
-- Set data
self.data = data
addRanges(self, 'v4Collection', IPv4Collection.new, data.ipv4Ranges)
addRanges(self, 'v6Collection', IPv6Collection.new, data.ipv6Ranges)
 
return self
end
end
 
function SensitiveEntity:matchesIPOrRange(str)
-- Returns true, matchObj, queryObj if there is a match for the IP address
-- string or CIDR range str in the sensitive entity. Returns false
-- otherwise. matchObj is the Subnet object that was matched, and queryObj
-- is the IPAddress or Subnet object corresponding to the input string.
 
-- Get the IPAddress or Subnet object for str
local isIP, isSubnet, obj
isIP, obj = pcall(IPAddress.new, str)
if isIP and not obj then
isIP = false
end
 
if not isIP then
isSubnet, obj = pcall(Subnet.new, str)
if not isSubnet or not obj then
error(string.format(
"'%s' is not a valid IP address or CIDR string",
str
), 2)
end
end
 
-- Try matching the object to the appropriate collection
local function isInCollection(collection, obj, isIP)
if isIP then
if collection then
local isMatch, matchObj = collection:containsIP(obj)
return isMatch, matchObj, obj
else
return false
end
else
if collection then
local isMatch, matchObj = collection:overlapsSubnet(obj)
return isMatch, matchObj, obj
else
return false
end
end
end
 
if obj:isIPv4() then
return isInCollection(self.v4Collection, obj, isIP)
else
return isInCollection(self.v6Collection, obj, isIP)
end
end
 
-------------------------------------------------------------------------------
Line 44 ⟶ 170:
-- {
-- entities = {'all'}
-- }
--
-- Query all entities and format the result as a JSON string:
-- {
-- entities = {'all'},
-- format = 'json'
-- }
--
Line 115 ⟶ 247:
-- }
-- }
--------------------------------------------------------------------------------
-- Q&D demo of loading data from [[Module:Sensitive IP addresses/list]]
-- into a structure that could be used to determine whether a particular
-- IP or subnet overlaps a sensitive range.
-- If used, this would be greatly refactored and possibly split to
-- [[Module:Sensitive IP addresses/data]].
--
-- Usage in a sandbox:
-- {{#invoke:Sensitive IP addresses|main}}
 
local function mainquery(options)
-- TestMake Module:IP.entity objects
local entities, entityIndexes = {}, {}
----------------------------------------------------------------------------
local data = mw.loadData('Module:Sensitive IP addresses/list')
-- An IP collection in Module:IP should hold both IPv4 and IPv6 lists and
for i, entityData in ipairs(data) do
-- it would use the appropriate list depending on the object queried?
entities[entityData.id] = SensitiveEntity.new(entityData)
-- That would make this code more straight forward.
entityIndexes[entityData.id] = i -- Keep track of the original order
----------------------------------------------------------------------------
-- Support stuff
----------------------------------------------------------------------------
local modcode = require('Module:IP')
local IPAddress = modcode.IPAddress
local Subnet = modcode.Subnet
local IPv4Collection = modcode.IPv4Collection
local IPv6Collection = modcode.IPv6Collection
local Collection = {}
Collection.__index = Collection
do
function Collection:add(item)
if item ~= nil then
self.n = self.n + 1
self[self.n] = item
end
end
function Collection:join(sep)
return table.concat(self, sep)
end
function Collection:sort(comp)
table.sort(self, comp)
end
function Collection.new()
return setmetatable({n = 0}, Collection)
end
end
 
local function getObject(ipStr)
local function makeError(code, info, format)
-- Parse a string and return an appropriate object:
local ret = {['error'] = {
-- IPv4 or IPv6 IP or subnet, or nil.
code = code,
-- TODO This should be in Module:IP (see IPCollection:_store).
info = info,
local maker
['*'] = 'See https://en.wikipedia.org/wiki/Module:Sensitive_IP_addresses/API for API usage',
if ipStr:find('/', 1, true) then
}}
maker = Subnet.new
if format == 'json' then
return mw.text.jsonEncode(ret)
else
return ret
maker = IPAddress.new
end
local success, obj = pcall(maker, ipStr)
if success then
return obj
end
return nil
end
 
local function preBlock(text)
-- Construct result
-- Pre tags returned by a module do not act like wikitext <pre>...</pre>.
local result = {
return '<pre>\n' ..
matches = {},
mw.text.nowiki(text) ..
['matched-ranges'] = {},
(text:sub(-1) == '\n' and '' or '\n') ..
entities = {},
'</pre>\n'
['entity-ids'] = {}
}
 
if type(options) ~= 'table' then
return makeError(
'sipa-options-type-error',
string.format(
"type error in argument #1 of 'query' (expected table, received %s)",
type(options)
)
)
elseif not options.test and not options.entities then
return makeError(
'sipa-blank-options',
"the options table didn't contain a 'test' or an 'entities' key",
options.format
)
end
 
----------------------------------------------------------------------------
if options.test then
-- Load sensitive IP information
if type(options.test) ~= 'table' then
----------------------------------------------------------------------------
return makeError(
local function loadList(modname)
'sipa-test-type-error',
-- Return a table to query an IP/subnet wrt sensitive ranges.
string.format(
local data = {
"'test' options key was type %s (expected table)",
subnetToInfo = {},
type(options.test)
v4Collection = IPv4Collection.new(),
),
v6Collection = IPv6Collection.new(),
options.format
}
)
local sensitiveList = mw.loadData(modname)
end
for i, info in ipairs(sensitiveList) do
 
for _, r in ipairs({
for i, testString in ipairs(options.test) do
{key = 'ipv4Ranges', list = data.v4Collection},
if type(testString) ~= 'string' then
{key = 'ipv6Ranges', list = data.v6Collection},
return makeError(
}) do
'sipa-test-string-type-error',
local rangeStrings = info[r.key]
string.format(
if rangeStrings then
"type error in item #%d in the 'test' array (expected string, received %s)",
for _, str in ipairs(rangeStrings) do
i,
local subnet = Subnet.new(str)
r.list:addSubnettype(subnettestString)
),
data.subnetToInfo[subnet] = info
options.format
)
end
 
for k, entity in pairs(entities) do
-- Try to match the range with the current sensitive entity.
local success, isMatch, matchObj, queryObj = pcall(
entity.matchesIPOrRange,
entity,
testString
)
if not success then
-- The string was invalid.
return makeError(
'sipa-invalid-test-string',
string.format(
"test string #%d '%s' was not a valid IP address or CIDR string",
i,
testString
),
options.format
)
end
if isMatch then
-- The string was a sensitive IP address or subnet.
 
-- Add match data
local match = {}
-- Quick and dirty hack to find if queryObj is an IPAddress object.
local isIP = queryObj.getNextIP ~= nil and queryObj.isInSubnet ~= nil
if isIP then
match.type = 'ip'
match.ip = tostring(queryObj)
else
match.type = 'range'
match.range = tostring(queryObj)
end
match['ip-version'] = queryObj:getVersion()
match['matches-range'] = matchObj:getCIDR()
match['entity-id'] = entity.data.id
table.insert(result.matches, match)
 
-- Add the matched range data.
result['matched-ranges'][match['matches-range']] = {
range = match['matches-range'],
['ip-version'] = match['ip-version'],
['entity-id'] = match['entity-id'],
}
 
-- Add the entity data for the entity we matched.
result.entities[match['entity-id']] = deepCopy(
entities[match['entity-id']].data
)
 
-- Add the entity ID for the entity we matched.
table.insert(result['entity-ids'], match['entity-id'])
end
end
end
return data
end
 
----------------------------------------------------------------------------
-- Add entity data requested explicitly.
-- Run test using Module:IP
if options.entities then
----------------------------------------------------------------------------
if type(options.entities) ~= 'table' then
local results = Collection.new()
return makeError(
local data = loadList('Module:Sensitive IP addresses/list')
'sipa-entities-type-error',
for _, ipStr in ipairs({
string.format(
-- Each of the following is tested against the sensitive list.
"'entities' options key was type %s (expected table)",
'143.228.19.123',
type(options.test)
'2620:0:E21:9F2::',
),
'131.132.224.0/19',
options.format
'198.35.27.255',
)
'2620:0:860::1',
end
'1.2.3.4',
 
'11.12.13.192/26',
-- Check the type of all the entity strings, and check if 'all' has
'2001:db8::abcd',
-- been specified.
'2001:db8::/72',
local isAll = false
}) do
for i, entityString in ipairs(options.entities) do
local obj = getObject(ipStr)
if objtype(entityString) ~= 'string' then
return makeError(
local isPresent, clashObj
'sipa-entity-string-type-error',
local col = obj:getVersion() == 'IPv4' and
string.format(
data.v4Collection or data.v6Collection
"type error in item #%d in the 'entities' array (expected string, received %s)",
if obj.getNextIP then -- dirty trick to check if obj is an IP
i,
isPresent, clashObj = col:containsIP(obj)
type(entityString)
else
),
isPresent, clashObj = col:overlapsSubnet(obj)
options.format
)
end
if entityString == 'all' then
results:add('')
isAll = true
results:add('IP or range under test: ' .. ipStr)
end
if isPresent then
end
local info = data.subnetToInfo[clashObj]
 
if info then
if isAll then
results:add(' sensitive: ' .. clashObj)
-- Add all the entity data.
results:add(' name: ' .. (info.name or '?'))
-- As the final result will contain all the entity data, we can
results:add(' id: ' .. (info.id or '?'))
-- just create the entities and entity-ids subtables from scratch
results:add(' description: ' .. (info.description or '?'))
-- without worrying about what any existing values might be.
results:add(' reason: ' .. (info.reason or '?'))
result.entities = {}
else
result['entity-ids'] = {}
-- Should not occur!
for i, entityData in ipairs(data) do
results:add(' info not found!')
result.entities[entityData.id] = deepCopy(entityData)
end
result['entity-ids'][i] = entityData.id
else
results:add(' not sensitive')
end
else
-- Add data for the entities specified.
-- Report problem?
-- Insert the entity and entity-id subtables if they aren't already
-- present.
for i, entityString in ipairs(options.entities) do
if entities[entityString] then
result.entities[entityString] = deepCopy(
entities[entityString].data
)
table.insert(result['entity-ids'], entityString)
end
end
result['entity-ids'] = removeDuplicates(result['entity-ids'])
table.sort(result['entity-ids'], function(s1, s2)
return entityIndexes[s1] < entityIndexes[s2]
end)
end
end
 
-- Add any missing reason fields from entities.
for id, entityData in pairs(result.entities) do
entityData.reason = entityData.reason or 'political'
end
 
-- Wrap the result in an outer layer like the MediaWiki Action API does.
result = {sensitiveips = result}
 
if options.format == 'json' then
-- Load jf-JSON
JSON = JSON or require('Module:jf-JSON')
JSON.strictTypes = true -- Necessary for correct blank-object encoding
-- Decode a skeleton result JSON string. This ensures that blank objects
-- are re-encoded as blank objects and not as blank arrays.
local jsonResult = JSON:decode([[{"sensitiveips": {
"matches": [],
"matched-ranges": {},
"entities": {},
"entity-ids": []
}}]])
for i, key in ipairs{'matches', 'matched-ranges', 'entities', 'entity-ids'} do
deepCopyInto(result.sensitiveips[key], jsonResult.sensitiveips[key])
end
return JSON:encode(jsonResult)
elseif options.format == nil or options.format == 'lua' then
return result
elseif type(options.format) ~= 'string' then
return makeError(
'sipa-format-type-error',
string.format(
"'format' options key was type %s (expected string or nil)",
type(options.format)
)
)
else
return makeError(
'sipa-invalid-format',
string.format(
"invalid format '%s' (expected 'json' or 'lua')",
type(options.format)
)
)
end
return preBlock(results:join('\n'))
end
 
Line 266 ⟶ 489:
 
local p = {}
p.main = main
 
function p.isValidSensitivityReason_isValidSensitivityReason(s)
-- Return true if s is a valid sensitivity reason; otherwise return false.
checkType('isValidSensitivityReason', 1, s, 'string')
return sensitivityReasonss ~= nil and SensitiveEntity.reasons[s] ~= nil
end
 
function p.getSensitivityReasons_getSensitivityReasons(separator, conjunction)
-- Return an string of valid sensitivity reasons, ordered alphabetically.
local ret = {}
-- The reasons are separated by an optional separator; if conjunction is
for reason in pairs(sensitivityReasons) do
-- specified it is used instead of the last separator, as in
ret[#ret + 1] = reason
-- mw.text.listToText.
 
-- Get an array of valid sensitivity reasons.
local reasons = {}
for reason in pairs(SensitiveEntity.reasons) do
reasons[#reasons + 1] = reason
end
table.sort(retreasons)
return ret
end
 
-- Convert arguments if we are being called from wikitext.
function p.query()
if type(separator) == 'table' and type(separator.getParent) == 'function' then
-- separator is a frame object
local frame = separator
separator = frame.args[1]
conjunction = frame.args[2]
end
 
-- Return a formatted string
return mw.text.listToText(reasons, separator, conjunction)
end
 
-- Export the API query function
p.query = query
 
return p