-
Notifications
You must be signed in to change notification settings - Fork 56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Replace Synapse antispam module with a "rule server" counterpart #78
Conversation
Fixes #37 **Note**: this completely replaces the existing Synapse antispam module. Admins are encouraged to upgrade upon release, though will have to make significant changes to how their configuration works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally LGTM, but see comments.
|
||
First, run the Docker image for the rule server. This is what will be serving the generated | ||
Python for the Synapse antispam module to read from. This rule server will serve the Python | ||
off a webserver at `/api/v1/py_rules` which must be accessible by wherever Synapse is installed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From my testing, the rules server appears to serve the rules from any endpoint, not just /api/v1/py_rules
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yea, that's technically a bug but the documentation should be what people expect to work.
# HACK: Private member access (_hs) | ||
resp = await self._api._hs.get_proxied_http_client().get_json(self._config['rules_url']) | ||
|
||
# *** !! DANGER !! *** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Python module looks generally sane and I don't see anything obviously wrong.
From a defence in depth perspective, I do have a general unease around using eval
on code received from a server. I understand this is coming from a trusted server, but it does make it easier for an attacker to escalate and move laterally in case he somehow manages to take control of the rules server.
Is there a particular reason why the rules need to be Python code instead of a small (e.g. JSON-based) DSL that is only able to encode the primitives that will reasonably be used for constructing ban expressions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with @dkasak that this knowingly leaves open a potential insecure vector. Note that the potentially generated rules (e.g. resp["checks"][...]["search"]
) seems to be one of:
event.get(..., '')
user_profile[...]
- A direct variable (e.g.
room_id
oruser_id
). UserID.from_string(user_id).domain
Some potential alternatives:
- Have the spam checker call the server for each query and handle a simple yes/no response. 🤷 This would only work for Synapse v1.25.0 where spam checker methods can be async and might have serious performance implications.
- Since the generated expressions are generally accessing variables or dictionary keys, have the server return a dotted dictionary accessor (e.g.
user_id
oruser_profile.user_id
and do some magic with splitting on.
, something like:
accessor = "user_profile.user_id" # this would be returned by the server
value = {"user_profile": user_profile}
for key in accessor.split("."):
value = getattr(value, key, "")
if re.search(check["pattern"], value):
return True
Note that something special would need to be done to get the user's domain, but that could be handled fairly easily by doing something like the following for the initial value:
value = {
"user_id": user_id,
"user_domain": UserID.from_string(user_id).domain,
}
The accessor would then be user_id
or user_domain
.
In general this approach seems overly flexible for the rules that get generated in RuleServer.ts
, but maybe there's a future plan I don't know about. Even my example above seems like it could be simplified quite a bit by knowing the data returned for each rule and what it gets applied against, which would also make the code a bit less abstract.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that if the dotted accessor approach is taken, the accessor keys should still be checked against a whitelist instead of allowing everything (which brings us back to a DSL). If a fully general accessor is allowed, it might be possible for an attacker to navigate the object to a secret string and then leak it by successively adapting a rule and checking whether it triggers.
rooms: false # rooms don't have usernames and can't be blocked. | ||
servers: false # the only rule which would apply is one for the local server. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why even have these in the default config if they don't apply?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
aside from the interface being easier to copy/paste and understand, mjolnir doesn't protect people from making mistakes almost by design - if someone really wanted to turn this switch on due to a massive attack of some sort, they could.
# HACK: Private member access (_hs) | ||
api._hs.get_clock().looping_call(self._update_rules, 5 * 1000) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exposing the clock as part of the module API would probably be reasonable, although that would quite widen the API surface we would have to consider stable.
# HACK: Private member access (_hs) | ||
api._hs.get_clock().looping_call(self._update_rules, 5 * 1000) | ||
|
||
# These are all arrays of compile()'d code from the ban list server. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Compiled code...that does what though? Are these lists of callables?
# HACK: Private member access (_hs) | ||
resp = await self._api._hs.get_proxied_http_client().get_json(self._config['rules_url']) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ModuleApi
object has an HTTP client available. It is not the proxied HTTP client, which should be fine though since rules_url
is likely internal and doesn't need a proxy?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah ha, this isn't documented :p
|
||
return defer.ensureDeferred(run()) | ||
|
||
def _compile_rules(self, rules): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would help if the inputs and outputs of these methods were documented a bit. It seems that rules
is a dictionary with a two keys:
search
: Python code as a string. It seems to be expected to be an expression that has access to some locally defined variables, which differ depending on the check being done.pattern
: A string regular expression.
A couple of potential improvements:
- Use an attrs class instead of a dictionary for the returned lists (this should use less memory and allow for more efficient access to properties).
- Pre-compile the regular expressions using
re.compile
and then callcheck["pattern"].search(search)
.
for check in self._code_spam_checks: | ||
params = { | ||
"event": event, | ||
"UserID": UserID, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing in a class here seems a bit scary to me, it might allow an attacker to modify the behavior of that class throughout Synapse via monkeypatching. (I suppose this is kind of true if you allow access to anything that isn't a primitive though.)
# HACK: Private member access (_hs) | ||
resp = await self._api._hs.get_proxied_http_client().get_json(self._config['rules_url']) | ||
|
||
# *** !! DANGER !! *** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with @dkasak that this knowingly leaves open a potential insecure vector. Note that the potentially generated rules (e.g. resp["checks"][...]["search"]
) seems to be one of:
event.get(..., '')
user_profile[...]
- A direct variable (e.g.
room_id
oruser_id
). UserID.from_string(user_id).domain
Some potential alternatives:
- Have the spam checker call the server for each query and handle a simple yes/no response. 🤷 This would only work for Synapse v1.25.0 where spam checker methods can be async and might have serious performance implications.
- Since the generated expressions are generally accessing variables or dictionary keys, have the server return a dotted dictionary accessor (e.g.
user_id
oruser_profile.user_id
and do some magic with splitting on.
, something like:
accessor = "user_profile.user_id" # this would be returned by the server
value = {"user_profile": user_profile}
for key in accessor.split("."):
value = getattr(value, key, "")
if re.search(check["pattern"], value):
return True
Note that something special would need to be done to get the user's domain, but that could be handled fairly easily by doing something like the following for the initial value:
value = {
"user_id": user_id,
"user_domain": UserID.from_string(user_id).domain,
}
The accessor would then be user_id
or user_domain
.
In general this approach seems overly flexible for the rules that get generated in RuleServer.ts
, but maybe there's a future plan I don't know about. Even my example above seems like it could be simplified quite a bit by knowing the data returned for each rule and what it gets applied against, which would also make the code a bit less abstract.
"UserID": UserID, | ||
} | ||
search = eval(check["search"], {}, params) | ||
if re.search(check["pattern"], search): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we ensure that search
is a string?
"event": event, | ||
"UserID": UserID, | ||
} | ||
search = eval(check["search"], {}, params) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can throw if the received code doesn't make sense with the input parameters. Right now this will raise an exception, is that the proper behavior?
Fixes #37
Synapse reviewer: I'm not expecting review on the JS/TS bits, just the Python parts to make sure I haven't violated every security principle on the planet. Due to the diff, it might make sense to review it from the raw view rather than the line view.
Note: this completely replaces the existing Synapse antispam module. Admins are encouraged to upgrade upon release, though will have to make significant changes to how their configuration works.