From b66657b3e6ee39491a717c2eac3c09adbd55d558 Mon Sep 17 00:00:00 2001 From: JustAnotherArchivist Date: Sat, 9 Oct 2021 02:59:14 +0000 Subject: [PATCH] Separate POST and GET auth Backwards-incompatible change: setting either of these to `false` means that all POST or GET requests are denied. (In practice, supporting unauthenticated requests were a bad idea anyway.) --- config.example.toml | 6 ++++-- http2irc.py | 52 ++++++++++++++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/config.example.toml b/config.example.toml index 709e550..c46b616 100644 --- a/config.example.toml +++ b/config.example.toml @@ -22,8 +22,10 @@ # The webpath must start with a slash. #webpath = '/spam' #ircchannel = '#spam' - # auth can be either 'user:pass' for basic authentication or false to disable auth - #auth = false + # postauth and getauth can be either 'user:pass' for basic authentication or false to disable POST/GET access on this endpoint + # For backwards compatibility, auth is treated as an alias of postauth. + #postauth = false + #getauth = false # module is the path to a Python source file that handles the message transformation. It must contain a coroutine function 'process' that takes one argument, the aiohttp.web.Request object, and returns the message string to be sent to IRC, which must not contain any linebreaks (CR or LF). It may raise an aiohttp.web.HTTPException to stop processing; any other exception will also cause the processing to be stopped and a '400 Bad Request' response to be returned to the client. The empty default value (None) causes the default processor to be used, which expects a straight message in the request body (with an optional trailing CR+LF or LF) and performs no transformations other than encoding the message as UTF-8. #module = # moduleargs are additional arguments to be passed into the module's process function after the request object. Example use: Gitea webhook secret key diff --git a/http2irc.py b/http2irc.py index 730820c..31ae084 100644 --- a/http2irc.py +++ b/http2irc.py @@ -154,7 +154,7 @@ class Config(dict): raise InvalidConfig(f'Invalid map key {key!r}') if not isinstance(map_, collections.abc.Mapping): raise InvalidConfig(f'Invalid map for {key!r}') - if any(x not in ('webpath', 'ircchannel', 'auth', 'module', 'moduleargs', 'overlongmode') for x in map_): + if any(x not in ('webpath', 'ircchannel', 'auth', 'postauth', 'getauth', 'module', 'moduleargs', 'overlongmode') for x in map_): raise InvalidConfig(f'Unknown key(s) found in map {key!r}') if 'webpath' not in map_: @@ -180,11 +180,19 @@ class Config(dict): if len(map_['ircchannel']) > 200: raise InvalidConfig(f'Invalid map {key!r} IRC channel: too long') + # For backward compatibility, 'auth' gets treated as 'postauth' if 'auth' in map_: - if map_['auth'] is not False and not isinstance(map_['auth'], str): - raise InvalidConfig(f'Invalid map {key!r} auth: must be false or a string') - if isinstance(map_['auth'], str) and ':' not in map_['auth']: - raise InvalidConfig(f'Invalid map {key!r} auth: must contain a colon') + if 'postauth' in map_: + raise InvalidConfig(f'auth and postauth are aliases and cannot be used together') + map_['postauth'] = map_['auth'] + del map_['auth'] + for k in ('postauth', 'getauth'): + if k not in map_: + continue + if map_[k] is not False and not isinstance(map_[k], str): + raise InvalidConfig(f'Invalid map {key!r} {k}: must be false or a string') + if isinstance(map_[k], str) and ':' not in map_[k]: + raise InvalidConfig(f'Invalid map {key!r} {k}: must contain a colon') if 'module' in map_: # If the path is relative, try to evaluate it relative to either the config file or this file; some modules are in the repo, but this also allows overriding them. @@ -217,8 +225,10 @@ class Config(dict): for key, map_ in obj['maps'].items(): # webpath is already set above for duplicate checking # ircchannel is set above for validation - if 'auth' not in map_: - map_['auth'] = False + if 'postauth' not in map_: + map_['postauth'] = False + if 'getauth' not in map_: + map_['getauth'] = False if 'module' not in map_: map_['module'] = None if 'moduleargs' not in map_: @@ -937,7 +947,9 @@ class WebServer: self.ircClient = ircClient self.config = config - self._paths = {} # '/path' => ('#channel', auth, module, moduleargs) where auth is either False (no authentication) or the HTTP header value for basic auth + self._paths = {} + # '/path' => ('#channel', postauth, getauth, module, moduleargs, overlongmode) + # {post,get}auth are either False (access denied) or the HTTP header value for basic auth self._app = aiohttp.web.Application() self._app.add_routes([ @@ -951,7 +963,14 @@ class WebServer: self.stopEvent = None def update_config(self, config): - self._paths = {map_['webpath']: (map_['ircchannel'], f'Basic {base64.b64encode(map_["auth"].encode("utf-8")).decode("utf-8")}' if map_['auth'] else False, map_['module'], map_['moduleargs'], map_['overlongmode']) for map_ in config['maps'].values()} + self._paths = {map_['webpath']: ( + map_['ircchannel'], + f'Basic {base64.b64encode(map_["postauth"].encode("utf-8")).decode("utf-8")}' if map_['postauth'] else False, + f'Basic {base64.b64encode(map_["getauth"].encode("utf-8")).decode("utf-8")}' if map_['getauth'] else False, + map_['module'], + map_['moduleargs'], + map_['overlongmode'] + ) for map_ in config['maps'].values()} needRebind = self.config['web'] != config['web'] self.config = config if needRebind: @@ -981,15 +1000,14 @@ class WebServer: except KeyError: self.logger.info(f'Bad request {id(request)}: no path {request.path!r}') raise aiohttp.web.HTTPNotFound() - auth = pathConfig[1] - if auth: - authHeader = request.headers.get('Authorization') - if not authHeader or authHeader != auth: - self.logger.info(f'Bad request {id(request)}: authentication failed: {authHeader!r} != {auth}') - raise aiohttp.web.HTTPForbidden() + auth = pathConfig[1] if request.method == 'POST' else pathConfig[2] + authHeader = request.headers.get('Authorization') + if not authHeader or not auth or authHeader != auth: + self.logger.info(f'Bad request {id(request)}: authentication failed: {authHeader!r} != {auth}') + raise aiohttp.web.HTTPForbidden() return (await func(request, *pathConfig)) - async def post(self, request, channel, auth, module, moduleargs, overlongmode): + async def post(self, request, channel, postauth, getauth, module, moduleargs, overlongmode): if module is not None: self.logger.debug(f'Processing request {id(request)} using {module!r}') try: @@ -1026,7 +1044,7 @@ class WebServer: raise aiohttp.web.HTTPBadRequest() return message - async def get(self, request, channel, auth, module, moduleargs, overlongmode): + async def get(self, request, channel, postauth, getauth, module, moduleargs, overlongmode): self.logger.info(f'Subscribing listener from request {id(request)} for {channel}') queue = self.irc2httpBroadcaster.subscribe(channel) response = aiohttp.web.StreamResponse()