Skip to content
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

Closed
wants to merge 2 commits into from

Conversation

turt2live
Copy link
Member

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.

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.
@turt2live turt2live requested a review from a team December 24, 2020 18:54
Copy link
Member

@dkasak dkasak left a 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.
Copy link
Member

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.

Copy link
Member Author

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 !! ***
Copy link
Member

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?

Copy link
Member

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 or user_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 or user_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.

Copy link
Member

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.

Comment on lines +206 to +207
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.
Copy link
Member

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?

Copy link
Member Author

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.

Comment on lines +32 to +33
# HACK: Private member access (_hs)
api._hs.get_clock().looping_call(self._update_rules, 5 * 1000)
Copy link
Member

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.
Copy link
Member

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?

Comment on lines +49 to +50
# HACK: Private member access (_hs)
resp = await self._api._hs.get_proxied_http_client().get_json(self._config['rules_url'])
Copy link
Member

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?

Copy link
Member Author

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):
Copy link
Member

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 call check["pattern"].search(search).

for check in self._code_spam_checks:
params = {
"event": event,
"UserID": UserID,
Copy link
Member

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 !! ***
Copy link
Member

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 or user_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 or user_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):
Copy link
Member

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)
Copy link
Member

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Synapse antispam module doesn't work in worker mode
4 participants