mirror of
https://github.com/Mikaela/Limnoria.git
synced 2024-11-26 12:49:24 +01:00
Add config variable supybot.commands.allowShell. (#1301)
This commit is contained in:
parent
82cdf17964
commit
4f6a5e7db9
@ -76,6 +76,29 @@ def getCapability(name):
|
|||||||
### Do more later, for specific capabilities/sections.
|
### Do more later, for specific capabilities/sections.
|
||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
def isReadOnly(name):
|
||||||
|
"""Prevents changing certain config variables to gain shell access via
|
||||||
|
a vulnerable IRC network."""
|
||||||
|
parts = registry.split(name.lower())
|
||||||
|
if parts[0] != 'supybot':
|
||||||
|
parts.insert(0, 'supybot')
|
||||||
|
if parts == ['supybot', 'commands', 'allowshell'] and \
|
||||||
|
not conf.supybot.commands.allowShell():
|
||||||
|
# allow setting supybot.commands.allowShell from True to False,
|
||||||
|
# but not from False to True.
|
||||||
|
# Otherwise an IRC network could overwrite it.
|
||||||
|
return True
|
||||||
|
elif parts[0:2] == ['supybot', 'directories'] and \
|
||||||
|
not conf.supybot.commands.allowShell():
|
||||||
|
# Setting plugins directory allows for arbitrary code execution if
|
||||||
|
# an attacker can both use the IRC network to MITM and upload files
|
||||||
|
# on the server (eg. with a web CMS).
|
||||||
|
# Setting other directories allows writing data at arbitrary
|
||||||
|
# locations.
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
def _reload():
|
def _reload():
|
||||||
ircdb.users.reload()
|
ircdb.users.reload()
|
||||||
ircdb.ignores.reload()
|
ircdb.ignores.reload()
|
||||||
@ -189,6 +212,10 @@ class Config(callbacks.Plugin):
|
|||||||
'available in this group.'))
|
'available in this group.'))
|
||||||
|
|
||||||
def _setValue(self, irc, msg, group, value):
|
def _setValue(self, irc, msg, group, value):
|
||||||
|
if isReadOnly(group._name):
|
||||||
|
irc.error(_('This configuration variable is not writeable '
|
||||||
|
'via IRC. To change it you have to: 1) use @flush 2) edit '
|
||||||
|
'the config file 3) use @config reload.'), Raise=True)
|
||||||
capability = getCapability(group._name)
|
capability = getCapability(group._name)
|
||||||
if ircdb.checkCapability(msg.prefix, capability):
|
if ircdb.checkCapability(msg.prefix, capability):
|
||||||
# I think callCommand catches exceptions here. Should it?
|
# I think callCommand catches exceptions here. Should it?
|
||||||
@ -294,6 +321,10 @@ class Config(callbacks.Plugin):
|
|||||||
command will export a "sanitized" configuration file suitable for
|
command will export a "sanitized" configuration file suitable for
|
||||||
showing publicly.
|
showing publicly.
|
||||||
"""
|
"""
|
||||||
|
if not conf.supybot.commands.allowShell():
|
||||||
|
# Disallow writing arbitrary files
|
||||||
|
irc.error('This command is not available, because '
|
||||||
|
'supybot.commands.allowShell is False.', Raise=True)
|
||||||
registry.close(conf.supybot, filename, private=False)
|
registry.close(conf.supybot, filename, private=False)
|
||||||
irc.replySuccess()
|
irc.replySuccess()
|
||||||
export = wrap(export, [('checkCapability', 'owner'), 'filename'])
|
export = wrap(export, [('checkCapability', 'owner'), 'filename'])
|
||||||
|
@ -80,5 +80,35 @@ class ConfigTestCase(ChannelPluginTestCase):
|
|||||||
'True or False.*, not \'123\'.')
|
'True or False.*, not \'123\'.')
|
||||||
self.assertRegexp('config supybot.replies foo', 'settable')
|
self.assertRegexp('config supybot.replies foo', 'settable')
|
||||||
|
|
||||||
|
def testReadOnly(self):
|
||||||
|
old_plugins_dirs = conf.supybot.directories.plugins()
|
||||||
|
try:
|
||||||
|
self.assertResponse('config supybot.commands.allowShell', 'True')
|
||||||
|
self.assertNotError('config supybot.directories.plugins dir1')
|
||||||
|
self.assertNotError('config supybot.commands.allowShell True')
|
||||||
|
self.assertResponse('config supybot.commands.allowShell', 'True')
|
||||||
|
self.assertResponse('config supybot.directories.plugins', 'dir1')
|
||||||
|
|
||||||
|
self.assertNotError('config supybot.commands.allowShell False')
|
||||||
|
self.assertResponse('config supybot.commands.allowShell', 'False')
|
||||||
|
|
||||||
|
self.assertRegexp('config supybot.directories.plugins dir2',
|
||||||
|
'Error.*not writeable')
|
||||||
|
self.assertResponse('config supybot.directories.plugins', 'dir1')
|
||||||
|
self.assertRegexp('config supybot.commands.allowShell True',
|
||||||
|
'Error.*not writeable')
|
||||||
|
self.assertResponse('config supybot.commands.allowShell', 'False')
|
||||||
|
|
||||||
|
self.assertRegexp('config commands.allowShell True',
|
||||||
|
'Error.*not writeable')
|
||||||
|
self.assertResponse('config supybot.commands.allowShell', 'False')
|
||||||
|
|
||||||
|
self.assertRegexp('config COMMANDS.ALLOWSHELL True',
|
||||||
|
'Error.*not writeable')
|
||||||
|
self.assertResponse('config supybot.commands.allowShell', 'False')
|
||||||
|
finally:
|
||||||
|
conf.supybot.commands.allowShell.setValue(True)
|
||||||
|
conf.supybot.directories.plugins.setValue(old_plugins_dirs)
|
||||||
|
|
||||||
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
|
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
|
||||||
|
|
||||||
|
@ -66,6 +66,12 @@ def getTracer(fd):
|
|||||||
fd.write('%s: %s\n' % (code.co_filename, code.co_name))
|
fd.write('%s: %s\n' % (code.co_filename, code.co_name))
|
||||||
return tracer
|
return tracer
|
||||||
|
|
||||||
|
def checkAllowShell(irc):
|
||||||
|
if not conf.supybot.commands.allowShell():
|
||||||
|
irc.error('This command is not available, because '
|
||||||
|
'supybot.commands.allowShell is False.', Raise=True)
|
||||||
|
|
||||||
|
|
||||||
class Debug(callbacks.Privmsg):
|
class Debug(callbacks.Privmsg):
|
||||||
"""This plugin provides debugging abilities for Supybot. It
|
"""This plugin provides debugging abilities for Supybot. It
|
||||||
should not be loaded with a default installation."""
|
should not be loaded with a default installation."""
|
||||||
@ -94,6 +100,7 @@ class Debug(callbacks.Privmsg):
|
|||||||
returns its value. If an exception is raised, reports the
|
returns its value. If an exception is raised, reports the
|
||||||
exception (and logs the traceback to the bot's logfile).
|
exception (and logs the traceback to the bot's logfile).
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
try:
|
try:
|
||||||
self._evalEnv.update(locals())
|
self._evalEnv.update(locals())
|
||||||
x = eval(s, self._evalEnv, self._evalEnv)
|
x = eval(s, self._evalEnv, self._evalEnv)
|
||||||
@ -110,6 +117,7 @@ class Debug(callbacks.Privmsg):
|
|||||||
|
|
||||||
Execs <code>. Returns success if it didn't raise any exceptions.
|
Execs <code>. Returns success if it didn't raise any exceptions.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
exec(s)
|
exec(s)
|
||||||
irc.replySuccess()
|
irc.replySuccess()
|
||||||
_exec = wrap(_exec, ['text'])
|
_exec = wrap(_exec, ['text'])
|
||||||
@ -119,6 +127,7 @@ class Debug(callbacks.Privmsg):
|
|||||||
|
|
||||||
Evaluates the given expression.
|
Evaluates the given expression.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
try:
|
try:
|
||||||
irc.reply(repr(eval(text)))
|
irc.reply(repr(eval(text)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -130,6 +139,7 @@ class Debug(callbacks.Privmsg):
|
|||||||
|
|
||||||
Raises the exception matching <exception name>.
|
Raises the exception matching <exception name>.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc) # Just to be safe, but probably not needed.
|
||||||
if isinstance(__builtins__, dict):
|
if isinstance(__builtins__, dict):
|
||||||
exn = __builtins__[name]
|
exn = __builtins__[name]
|
||||||
else:
|
else:
|
||||||
@ -152,6 +162,7 @@ class Debug(callbacks.Privmsg):
|
|||||||
Starts tracing function calls to <filename>. If <filename> is not
|
Starts tracing function calls to <filename>. If <filename> is not
|
||||||
given, sys.stdout is used. This causes much output.
|
given, sys.stdout is used. This causes much output.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
if filename:
|
if filename:
|
||||||
fd = open(filename, 'a')
|
fd = open(filename, 'a')
|
||||||
else:
|
else:
|
||||||
@ -165,6 +176,7 @@ class Debug(callbacks.Privmsg):
|
|||||||
|
|
||||||
Stops tracing function calls on stdout.
|
Stops tracing function calls on stdout.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
sys.settrace(None)
|
sys.settrace(None)
|
||||||
irc.replySuccess()
|
irc.replySuccess()
|
||||||
unsettrace = wrap(unsettrace)
|
unsettrace = wrap(unsettrace)
|
||||||
@ -195,6 +207,7 @@ class Debug(callbacks.Privmsg):
|
|||||||
|
|
||||||
Returns the environment of the supybot process.
|
Returns the environment of the supybot process.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc) # possibly some secret data in the env
|
||||||
irc.reply(repr(os.environ))
|
irc.reply(repr(os.environ))
|
||||||
environ = wrap(environ)
|
environ = wrap(environ)
|
||||||
|
|
||||||
|
@ -33,4 +33,21 @@ class DebugTestCase(PluginTestCase):
|
|||||||
plugins = ('Debug',)
|
plugins = ('Debug',)
|
||||||
|
|
||||||
|
|
||||||
|
def testShellForbidden(self):
|
||||||
|
self.assertResponse('debug eval 1+2', '3')
|
||||||
|
self.assertResponse('debug simpleeval 1+2', '3')
|
||||||
|
self.assertResponse('debug exec irc.reply(1+2)', '3')
|
||||||
|
while self.irc.takeMsg():
|
||||||
|
pass
|
||||||
|
self.assertNotError('debug environ')
|
||||||
|
with conf.supybot.commands.allowShell.context(False):
|
||||||
|
self.assertRegexp('debug eval 1+2',
|
||||||
|
'Error:.*not available.*supybot.commands.allowShell')
|
||||||
|
self.assertRegexp('debug simpleeval 1+2',
|
||||||
|
'Error:.*not available.*supybot.commands.allowShell')
|
||||||
|
self.assertRegexp('debug exec irc.reply(1+2)',
|
||||||
|
'Error:.*not available.*supybot.commands.allowShell')
|
||||||
|
self.assertRegexp('debug environ',
|
||||||
|
'Error:.*not available.*supybot.commands.allowShell')
|
||||||
|
|
||||||
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
|
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
|
||||||
|
@ -45,6 +45,11 @@ _ = PluginInternationalization('Karma')
|
|||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
def checkAllowShell(irc):
|
||||||
|
if not conf.supybot.commands.allowShell():
|
||||||
|
irc.error('This command is not available, because '
|
||||||
|
'supybot.commands.allowShell is False.', Raise=True)
|
||||||
|
|
||||||
class SqliteKarmaDB(object):
|
class SqliteKarmaDB(object):
|
||||||
def __init__(self, filename):
|
def __init__(self, filename):
|
||||||
self.dbs = ircutils.IrcDict()
|
self.dbs = ircutils.IrcDict()
|
||||||
@ -395,6 +400,7 @@ class Karma(callbacks.Plugin):
|
|||||||
data directory. <channel> is only necessary if the message isn't sent
|
data directory. <channel> is only necessary if the message isn't sent
|
||||||
in the channel itself.
|
in the channel itself.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
self.db.dump(channel, filename)
|
self.db.dump(channel, filename)
|
||||||
irc.replySuccess()
|
irc.replySuccess()
|
||||||
dump = wrap(dump, [('checkCapability', 'owner'), 'channeldb', 'filename'])
|
dump = wrap(dump, [('checkCapability', 'owner'), 'channeldb', 'filename'])
|
||||||
@ -407,6 +413,7 @@ class Karma(callbacks.Plugin):
|
|||||||
data directory. <channel> is only necessary if the message isn't sent
|
data directory. <channel> is only necessary if the message isn't sent
|
||||||
in the channel itself.
|
in the channel itself.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
self.db.load(channel, filename)
|
self.db.load(channel, filename)
|
||||||
irc.replySuccess()
|
irc.replySuccess()
|
||||||
load = wrap(load, [('checkCapability', 'owner'), 'channeldb', 'filename'])
|
load = wrap(load, [('checkCapability', 'owner'), 'channeldb', 'filename'])
|
||||||
|
@ -359,6 +359,9 @@ class PluginDownloader(callbacks.Plugin):
|
|||||||
"""<repository> <plugin>
|
"""<repository> <plugin>
|
||||||
|
|
||||||
Downloads and installs the <plugin> from the <repository>."""
|
Downloads and installs the <plugin> from the <repository>."""
|
||||||
|
if not conf.supybot.commands.allowShell():
|
||||||
|
irc.error(_('This command is not available, because '
|
||||||
|
'supybot.commands.allowShell is False.'), Raise=True)
|
||||||
global repositories
|
global repositories
|
||||||
if repository not in repositories:
|
if repository not in repositories:
|
||||||
irc.error(_(
|
irc.error(_(
|
||||||
|
@ -71,6 +71,11 @@ class PluginDownloaderTestCase(PluginTestCase):
|
|||||||
self.assertError('plugindownloader install ProgVal Darcs')
|
self.assertError('plugindownloader install ProgVal Darcs')
|
||||||
self._testPluginInstalled('AttackProtector')
|
self._testPluginInstalled('AttackProtector')
|
||||||
|
|
||||||
|
def testShellForbidden(self):
|
||||||
|
with conf.supybot.commands.allowShell.context(False):
|
||||||
|
self.assertRegexp('plugindownloader install ProgVal Darcs',
|
||||||
|
'Error:.*not available.*supybot.commands.allowShell')
|
||||||
|
|
||||||
def testInstallQuantumlemur(self):
|
def testInstallQuantumlemur(self):
|
||||||
self.assertError('plugindownloader install quantumlemur AttackProtector')
|
self.assertError('plugindownloader install quantumlemur AttackProtector')
|
||||||
self.assertNotError('plugindownloader install quantumlemur Listener')
|
self.assertNotError('plugindownloader install quantumlemur Listener')
|
||||||
|
@ -40,6 +40,7 @@ import struct
|
|||||||
import subprocess
|
import subprocess
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
|
import supybot.conf as conf
|
||||||
import supybot.utils as utils
|
import supybot.utils as utils
|
||||||
from supybot.commands import *
|
from supybot.commands import *
|
||||||
import supybot.utils.minisix as minisix
|
import supybot.utils.minisix as minisix
|
||||||
@ -50,6 +51,11 @@ import supybot.callbacks as callbacks
|
|||||||
from supybot.i18n import PluginInternationalization, internationalizeDocstring
|
from supybot.i18n import PluginInternationalization, internationalizeDocstring
|
||||||
_ = PluginInternationalization('Unix')
|
_ = PluginInternationalization('Unix')
|
||||||
|
|
||||||
|
def checkAllowShell(irc):
|
||||||
|
if not conf.supybot.commands.allowShell():
|
||||||
|
irc.error(_('This command is not available, because '
|
||||||
|
'supybot.commands.allowShell is False.'), Raise=True)
|
||||||
|
|
||||||
_progstats_endline_remover = utils.str.MultipleRemover('\r\n')
|
_progstats_endline_remover = utils.str.MultipleRemover('\r\n')
|
||||||
def progstats():
|
def progstats():
|
||||||
pw = pwd.getpwuid(os.getuid())
|
pw = pwd.getpwuid(os.getuid())
|
||||||
@ -401,6 +407,7 @@ class Unix(callbacks.Plugin):
|
|||||||
you don't run anything that will spamify your channel or that
|
you don't run anything that will spamify your channel or that
|
||||||
will bring your machine to its knees.
|
will bring your machine to its knees.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
self.log.info('Unix: running command "%s" for %s/%s', text, msg.nick,
|
self.log.info('Unix: running command "%s" for %s/%s', text, msg.nick,
|
||||||
irc.network)
|
irc.network)
|
||||||
args = shlex.split(text)
|
args = shlex.split(text)
|
||||||
@ -433,6 +440,7 @@ class Unix(callbacks.Plugin):
|
|||||||
you don't run anything that will spamify your channel or that
|
you don't run anything that will spamify your channel or that
|
||||||
will bring your machine to its knees.
|
will bring your machine to its knees.
|
||||||
"""
|
"""
|
||||||
|
checkAllowShell(irc)
|
||||||
self.log.info('Unix: running command "%s" for %s/%s', text, msg.nick,
|
self.log.info('Unix: running command "%s" for %s/%s', text, msg.nick,
|
||||||
irc.network)
|
irc.network)
|
||||||
try:
|
try:
|
||||||
|
@ -172,6 +172,12 @@ if os.name == 'posix':
|
|||||||
self.assertRegexp('unix call /bin/ls /', 'boot, .*dev, ')
|
self.assertRegexp('unix call /bin/ls /', 'boot, .*dev, ')
|
||||||
self.assertError('unix call /usr/bin/nosuchcommandaoeuaoeu')
|
self.assertError('unix call /usr/bin/nosuchcommandaoeuaoeu')
|
||||||
|
|
||||||
|
def testShellForbidden(self):
|
||||||
|
self.assertNotError('unix call /bin/ls /')
|
||||||
|
with conf.supybot.commands.allowShell.context(False):
|
||||||
|
self.assertRegexp('unix call /bin/ls /',
|
||||||
|
'Error:.*not available.*supybot.commands.allowShell')
|
||||||
|
|
||||||
def testUptime(self):
|
def testUptime(self):
|
||||||
self.assertNotError('unix sysuptime')
|
self.assertNotError('unix sysuptime')
|
||||||
|
|
||||||
|
10
src/conf.py
10
src/conf.py
@ -748,6 +748,16 @@ registerGlobalValue(supybot.commands.defaultPlugins, 'importantPlugins',
|
|||||||
know what you're doing, then also know that this set is
|
know what you're doing, then also know that this set is
|
||||||
case-sensitive.""")))
|
case-sensitive.""")))
|
||||||
|
|
||||||
|
# For this config variable to make sense, it must no be writable via IRC.
|
||||||
|
# Make sure it is always blacklisted from the Config plugin.
|
||||||
|
registerGlobalValue(supybot.commands, 'allowShell',
|
||||||
|
registry.Boolean(True, _("""Allows this bot's owner user to use commands
|
||||||
|
that grants them shell access. This config variable exists in case you want
|
||||||
|
to prevent MITM from the IRC network itself (vulnerable IRCd or IRCops)
|
||||||
|
from gaining shell access to the bot's server by impersonating the owner.
|
||||||
|
Setting this to False also disables plugins and commands that can be
|
||||||
|
used to indirectly gain shell access.""")))
|
||||||
|
|
||||||
# supybot.commands.disabled moved to callbacks for canonicalName.
|
# supybot.commands.disabled moved to callbacks for canonicalName.
|
||||||
|
|
||||||
###
|
###
|
||||||
|
Loading…
Reference in New Issue
Block a user