3
0
mirror of https://github.com/jlu5/PyLink.git synced 2024-12-11 20:49:33 +01:00

relay: implement kick/mode/topic war prevention (#23)

This adds cachetools as a dependency for Relay.
This commit is contained in:
James Lu 2019-06-21 14:57:43 -07:00
parent 042d11d7ba
commit 94cd1d8f22

View File

@ -13,6 +13,11 @@ from pylinkirc.coremods import permissions
CHANNEL_DELINKED_MSG = "Channel delinked." CHANNEL_DELINKED_MSG = "Channel delinked."
RELAY_UNLOADED_MSG = "Relay plugin unloaded." RELAY_UNLOADED_MSG = "Relay plugin unloaded."
try:
import cachetools
except ImportError as e:
raise ImportError("PyLink Relay requires cachetools as of PyLink 2.1: https://pypi.org/project/cachetools/") from e
try: try:
import unidecode import unidecode
except ImportError: except ImportError:
@ -27,6 +32,11 @@ relayservers = defaultdict(dict)
spawnlocks = defaultdict(threading.Lock) spawnlocks = defaultdict(threading.Lock)
spawnlocks_servers = defaultdict(threading.Lock) spawnlocks_servers = defaultdict(threading.Lock)
# Claim bounce cache to prevent kick/mode/topic loops
__claim_bounce_timeout = conf.conf.get('relay', {}).get('claim_bounce_timeout', 5)
claim_bounce_cache = cachetools.TTLCache(float('inf'), __claim_bounce_timeout)
claim_bounce_cache_lock = threading.Lock()
dbname = conf.get_database_name('pylinkrelay') dbname = conf.get_database_name('pylinkrelay')
datastore = structures.PickleDataStore('pylinkrelay', dbname) datastore = structures.PickleDataStore('pylinkrelay', dbname)
db = datastore.store db = datastore.store
@ -646,9 +656,32 @@ def remove_channel(irc, channel):
del relayusers[remoteuser][irc.name] del relayusers[remoteuser][irc.name]
irc.quit(user, 'Left all shared channels.') irc.quit(user, 'Left all shared channels.')
def _claim_should_bounce(irc, channel):
"""
Returns whether we should bounce the next action that fails CLAIM.
This is used to prevent kick/mode/topic wars with services.
"""
with claim_bounce_cache_lock:
if irc.name not in claim_bounce_cache: # Nothing in the cache to worry about
return True
limit = irc.get_service_option('relay', 'claim_bounce_limit', default=15)
if limit < 0: # Disabled
return True
elif limit < 5: # Anything below this is just asking for desyncs...
log.warning('(%s) relay: the minimum supported value for relay::claim_bounce_limit is 5.', irc.name)
limit = 5
success = claim_bounce_cache[irc.name] <= limit
ttl = claim_bounce_cache.ttl
if not success:
log.warning("(%s) relay: %s received more than %s claim bounces in %s seconds - your channel may be desynced!",
irc.name, channel, limit, ttl)
return success
def check_claim(irc, channel, sender, chanobj=None): def check_claim(irc, channel, sender, chanobj=None):
""" """
Checks whether the sender of a kick/mode change passes CLAIM checks for Checks whether the sender of a kick/mode/topic change passes CLAIM checks for
a given channel. This returns True if any of the following criteria are met: a given channel. This returns True if any of the following criteria are met:
1) No relay exists for the channel in question. 1) No relay exists for the channel in question.
@ -669,13 +702,23 @@ def check_claim(irc, channel, sender, chanobj=None):
log.debug('(%s) relay.check_claim: sender modes (%s/%s) are %s (mlist=%s)', irc.name, log.debug('(%s) relay.check_claim: sender modes (%s/%s) are %s (mlist=%s)', irc.name,
sender, channel, sender_modes, mlist) sender, channel, sender_modes, mlist)
# XXX: stop hardcoding modes to check for and support mlist in isHalfopPlus and friends # XXX: stop hardcoding modes to check for and support mlist in isHalfopPlus and friends
return (not relay) or irc.name == relay[0] or not db[relay]['claim'] or \ success = (not relay) or irc.name == relay[0] or not db[relay]['claim'] or \
irc.name in db[relay]['claim'] or \ irc.name in db[relay]['claim'] or \
(any([mode in sender_modes for mode in ('y', 'q', 'a', 'o', 'h')]) (any([mode in sender_modes for mode in {'y', 'q', 'a', 'o', 'h'}])
and not irc.is_privileged_service(sender)) \ and not irc.is_privileged_service(sender)) \
or irc.is_internal_client(sender) or \ or irc.is_internal_client(sender) or \
irc.is_internal_server(sender) irc.is_internal_server(sender)
# Increment claim_bounce_cache, checked in _claim_should_bounce()
if not success:
with claim_bounce_cache_lock:
if irc.name not in claim_bounce_cache:
claim_bounce_cache[irc.name] = 1
else:
claim_bounce_cache[irc.name] += 1
return success
def get_supported_umodes(irc, remoteirc, modes): def get_supported_umodes(irc, remoteirc, modes):
"""Given a list of user modes, filters out all of those not supported by the """Given a list of user modes, filters out all of those not supported by the
remote network.""" remote network."""
@ -1318,7 +1361,7 @@ def handle_join(irc, numeric, command, args):
if modechar and not irc.is_privileged_service(numeric): if modechar and not irc.is_privileged_service(numeric):
modes.append(('-%s' % modechar, user)) modes.append(('-%s' % modechar, user))
if modes: if modes and _claim_should_bounce(irc, channel):
log.debug('(%s) relay.handle_join: reverting modes on BURST: %s', irc.name, irc.join_modes(modes)) log.debug('(%s) relay.handle_join: reverting modes on BURST: %s', irc.name, irc.join_modes(modes))
irc.mode(irc.sid, channel, modes) irc.mode(irc.sid, channel, modes)
@ -1695,32 +1738,31 @@ def handle_kick(irc, source, command, args):
del relayusers[(irc.name, target)][remoteirc.name] del relayusers[(irc.name, target)][remoteirc.name]
remoteirc.quit(real_target, 'Left all shared channels.') remoteirc.quit(real_target, 'Left all shared channels.')
# Kick was a relay client but sender does not pass CLAIM restrictions. Bounce a rejoin unless we've reached our limit.
if is_relay_client(irc, target) and not check_claim(irc, channel, kicker): if is_relay_client(irc, target) and not check_claim(irc, channel, kicker):
homenet, real_target = get_orig_user(irc, target) if _claim_should_bounce(irc, channel):
homeirc = world.networkobjects.get(homenet) homenet, real_target = get_orig_user(irc, target)
homenick = homeirc.users[real_target].nick if homeirc else '<ghost user>' homeirc = world.networkobjects.get(homenet)
homechan = get_remote_channel(irc, homeirc, channel) homenick = homeirc.users[real_target].nick if homeirc else '<ghost user>'
homechan = get_remote_channel(irc, homeirc, channel)
log.debug('(%s) relay.handle_kick: kicker %s is not opped... We should rejoin the target user %s', irc.name, kicker, real_target) log.debug('(%s) relay.handle_kick: kicker %s is not opped... We should rejoin the target user %s', irc.name, kicker, real_target)
# Home network is not in the channel's claim AND the kicker is not # FIXME: make the check slightly more advanced: i.e. halfops can't kick ops, admins can't kick owners, etc.
# opped. We won't propograte the kick then. modes = get_prefix_modes(homeirc, irc, homechan, real_target)
# TODO: make the check slightly more advanced: i.e. halfops can't
# kick ops, admins can't kick owners, etc.
modes = get_prefix_modes(homeirc, irc, homechan, real_target)
# Join the kicked client back with its respective modes. # Join the kicked client back with its respective modes.
irc.sjoin(irc.sid, channel, [(modes, target)]) irc.sjoin(irc.sid, channel, [(modes, target)])
if kicker in irc.users: if kicker in irc.users:
log.info('(%s) relay: Blocked KICK (reason %r) from %s/%s to %s/%s on %s.', log.info('(%s) relay: Blocked KICK (reason %r) from %s/%s to %s/%s on %s.',
irc.name, args['text'], irc.users[source].nick, irc.name, irc.name, args['text'], irc.users[source].nick, irc.name,
homenick, homenet, channel) homenick, homenet, channel)
irc.msg(kicker, "This channel is claimed; your kick to " irc.msg(kicker, "This channel is claimed; your kick to "
"%s has been blocked because you are not " "%s has been blocked because you are not "
"(half)opped." % channel, notice=True) "(half)opped." % channel, notice=True)
else: else:
log.info('(%s) relay: Blocked KICK (reason %r) from server %s to %s/%s on %s.', log.info('(%s) relay: Blocked KICK (reason %r) from server %s to %s/%s on %s.',
irc.name, args['text'], irc.servers[source].name , irc.name, args['text'], irc.servers[source].name ,
homenick, homenet, channel) homenick, homenet, channel)
return return
iterate_all(irc, _handle_kick_loop, extra_args=(source, command, args)) iterate_all(irc, _handle_kick_loop, extra_args=(source, command, args))
@ -1838,22 +1880,23 @@ def handle_mode(irc, numeric, command, args):
for named_modepair in modedelta_modes])) for named_modepair in modedelta_modes]))
if not check_claim(irc, target, numeric, chanobj=oldchan): if not check_claim(irc, target, numeric, chanobj=oldchan):
# Mode change blocked by CLAIM. if _claim_should_bounce(irc, target):
reversed_modes = irc.reverse_modes(target, modes, oldobj=oldchan) # Mode change blocked by CLAIM.
reversed_modes = irc.reverse_modes(target, modes, oldobj=oldchan)
if irc.is_privileged_service(numeric): if irc.is_privileged_service(numeric):
# Special hack for "U-lined" servers - ignore changes to SIMPLE modes and # Special hack for "U-lined" servers - ignore changes to SIMPLE modes and
# attempts to op u-lined clients (trying to change status for others # attempts to op its own clients (trying to change status for others
# SHOULD be reverted). # SHOULD be reverted).
# This is for compatibility with Anope's DEFCON for the most part, as well as # This is for compatibility with Anope's DEFCON for the most part, as well as
# silly people who try to register a channel multiple times via relay. # silly people who try to register a channel multiple times via relay.
reversed_modes = [modepair for modepair in reversed_modes if reversed_modes = [modepair for modepair in reversed_modes if
# Mode is a prefix mode but target isn't ulined, revert # Include prefix modes if target isn't also U-lined
((modepair[0][-1] in irc.prefixmodes and not ((modepair[0][-1] in irc.prefixmodes and not
irc.is_privileged_service(modepair[1])) irc.is_privileged_service(modepair[1]))
# Tried to set a list mode, revert # Include all list modes (bans, etc.)
or modepair[0][-1] in irc.cmodes['*A']) or modepair[0][-1] in irc.cmodes['*A'])
] ]
modes.clear() # Clear the mode list so nothing is relayed below modes.clear() # Clear the mode list so nothing is relayed below
for modepair in modes.copy(): for modepair in modes.copy():
@ -1920,7 +1963,7 @@ def handle_topic(irc, numeric, command, args):
remoteirc.topic_burst(rsid, remotechan, topic) remoteirc.topic_burst(rsid, remotechan, topic)
iterate_all(irc, _handle_topic_loop, extra_args=(numeric, command, args)) iterate_all(irc, _handle_topic_loop, extra_args=(numeric, command, args))
elif oldtopic: # Topic change blocked by claim. elif oldtopic and _claim_should_bounce(irc, channel): # Topic change blocked by claim.
irc.topic_burst(irc.sid, channel, oldtopic) irc.topic_burst(irc.sid, channel, oldtopic)
utils.add_hook(handle_topic, 'TOPIC') utils.add_hook(handle_topic, 'TOPIC')