Browse Source

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.)
master
JustAnotherArchivist 2 years ago
parent
commit
b66657b3e6
2 changed files with 39 additions and 19 deletions
  1. +4
    -2
      config.example.toml
  2. +35
    -17
      http2irc.py

+ 4
- 2
config.example.toml View File

@@ -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


+ 35
- 17
http2irc.py View File

@@ -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()


Loading…
Cancel
Save