mirror of https://github.com/pissnet/pissircd.git
588 lines
15 KiB
C
588 lines
15 KiB
C
/* src/modules/spamreport.c - spamreport { } and /SPAMREPORT cmd
|
|
* (C) Copyright 2023 Bram Matthys (Syzop) and the UnrealIRCd team
|
|
* License: GPLv2 or later
|
|
*/
|
|
#include "unrealircd.h"
|
|
|
|
ModuleHeader MOD_HEADER
|
|
= {
|
|
"spamreport",
|
|
"1.0.0",
|
|
"Send spam reports via SPAMREPORT and spamreport { } blocks",
|
|
"UnrealIRCd Team",
|
|
"unrealircd-6",
|
|
};
|
|
|
|
/** For rate limiting the rate limit message :D */
|
|
#define SPAMREPORT_RATE_LIMIT_WARNING_EVERY 15
|
|
|
|
/* Enums and structs */
|
|
typedef enum SpamreportType {
|
|
SPAMREPORT_TYPE_SIMPLE = 1,
|
|
SPAMREPORT_TYPE_DRONEBL = 2,
|
|
SPAMREPORT_TYPE_CENTRAL_SPAMREPORT = 3,
|
|
} SpamreportType;
|
|
|
|
typedef struct Spamreport Spamreport;
|
|
struct Spamreport {
|
|
Spamreport *prev, *next;
|
|
char *name; /**< Name of the block, spamreport <this> { } */
|
|
char *url; /**< URL to use */
|
|
SpamreportType type;
|
|
HttpMethod http_method;
|
|
NameValuePrioList *parameters;
|
|
SecurityGroup *except;
|
|
int rate_limit_count;
|
|
int rate_limit_period;
|
|
};
|
|
|
|
typedef struct SpamreportCounter SpamreportCounter;
|
|
struct SpamreportCounter {
|
|
SpamreportCounter *prev, *next;
|
|
char *name;
|
|
long long rate_limit_time;
|
|
int rate_limit_count;
|
|
time_t last_warning_sent;
|
|
};
|
|
|
|
/* Forward declarations */
|
|
CMD_FUNC(cmd_spamreport);
|
|
int tkl_config_test_spamreport(ConfigFile *, ConfigEntry *, int, int *);
|
|
int tkl_config_run_spamreport(ConfigFile *, ConfigEntry *, int);
|
|
Spamreport *find_spamreport_block(const char *name);
|
|
void free_spamreport_blocks(void);
|
|
int _spamreport(Client *client, const char *ip, NameValuePrioList *details, const char *spamreport_block, Client *by);
|
|
int _central_spamreport_enabled(void);
|
|
void spamreportcounters_free_all(ModData *m);
|
|
SpamreportType parse_spamreport_type(const char *s);
|
|
|
|
/* Variables */
|
|
Spamreport *spamreports = NULL;
|
|
SpamreportCounter *spamreportcounters = NULL;
|
|
|
|
MOD_TEST()
|
|
{
|
|
MARK_AS_OFFICIAL_MODULE(modinfo);
|
|
EfunctionAdd(modinfo->handle, EFUNC_SPAMREPORT, _spamreport);
|
|
EfunctionAdd(modinfo->handle, EFUNC_CENTRAL_SPAMREPORT_ENABLED, _central_spamreport_enabled);
|
|
HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, tkl_config_test_spamreport);
|
|
return MOD_SUCCESS;
|
|
}
|
|
|
|
MOD_INIT()
|
|
{
|
|
MARK_AS_OFFICIAL_MODULE(modinfo);
|
|
CommandAdd(modinfo->handle, "SPAMREPORT", cmd_spamreport, MAXPARA, CMD_USER);
|
|
HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, tkl_config_run_spamreport);
|
|
LoadPersistentPointer(modinfo, spamreportcounters, spamreportcounters_free_all);
|
|
return MOD_SUCCESS;
|
|
}
|
|
|
|
MOD_LOAD()
|
|
{
|
|
return MOD_SUCCESS;
|
|
}
|
|
|
|
MOD_UNLOAD()
|
|
{
|
|
SavePersistentPointer(modinfo, spamreportcounters);
|
|
free_spamreport_blocks();
|
|
return MOD_SUCCESS;
|
|
}
|
|
|
|
/** Test a spamreport { } block in the configuration file */
|
|
int tkl_config_test_spamreport(ConfigFile *cf, ConfigEntry *ce, int type, int *errs)
|
|
{
|
|
ConfigEntry *cep, *cepp;
|
|
int errors = 0;
|
|
char has_url=0, has_type=0, has_type_dronebl=0, has_http_method=0;
|
|
char has_dronebl_type=0, has_dronebl_rpckey=0;
|
|
|
|
/* We are only interested in spamreport { } blocks */
|
|
if ((type != CONFIG_MAIN) || strcmp(ce->name, "spamreport"))
|
|
return 0;
|
|
|
|
if (!ce->value)
|
|
{
|
|
config_error("%s:%i: spamreport block has no name, should be like: spamfilter <name> { }",
|
|
ce->file->filename, ce->line_number);
|
|
errors++;
|
|
}
|
|
|
|
for (cep = ce->items; cep; cep = cep->next)
|
|
{
|
|
if (!strcmp(cep->name, "except"))
|
|
{
|
|
test_match_block(cf, cep, &errors);
|
|
} else
|
|
if (!strcmp(cep->name, "parameters"))
|
|
{
|
|
for (cepp = cep->items; cepp; cepp = cepp->next)
|
|
{
|
|
if (!cepp->value)
|
|
{
|
|
config_error_empty(cepp->file->filename, cepp->line_number,
|
|
"spamreport::parameters", cepp->name);
|
|
errors++;
|
|
}
|
|
else if (!strcmp(cepp->name, "rpckey"))
|
|
has_dronebl_rpckey = 1;
|
|
else if (!strcmp(cepp->name, "type"))
|
|
has_dronebl_type = 1;
|
|
else if (!strcmp(cepp->name, "staging"))
|
|
;
|
|
}
|
|
}
|
|
else if (!cep->value)
|
|
{
|
|
config_error_empty(cep->file->filename, cep->line_number,
|
|
"spamreport", cep->name);
|
|
errors++;
|
|
continue;
|
|
} else
|
|
if (!strcmp(cep->name, "url"))
|
|
{
|
|
if (has_url)
|
|
{
|
|
config_warn_duplicate(cep->file->filename,
|
|
cep->line_number, "spamreport::url");
|
|
continue;
|
|
}
|
|
has_url = 1;
|
|
}
|
|
else if (!strcmp(cep->name, "type"))
|
|
{
|
|
if (has_type)
|
|
{
|
|
config_warn_duplicate(cep->file->filename,
|
|
cep->line_number, "spamreport::type");
|
|
continue;
|
|
}
|
|
has_type = parse_spamreport_type(cep->value);
|
|
if (!has_type)
|
|
{
|
|
config_error("%s:%i: spamreport::type: unknown type '%s', supported types are: simple, dronebl, central-spamreport.",
|
|
cep->file->filename, cep->line_number, cep->value);
|
|
errors++;
|
|
}
|
|
}
|
|
else if (!strcmp(cep->name, "http-method"))
|
|
{
|
|
if (has_http_method)
|
|
{
|
|
config_warn_duplicate(cep->file->filename,
|
|
cep->line_number, "spamreport::http-method");
|
|
continue;
|
|
}
|
|
has_http_method = 1;
|
|
if (strcmp(cep->value, "get") && strcmp(cep->value, "post"))
|
|
{
|
|
config_error("%s:%i: spamreport::http-method: only 'get' and 'post' are supported",
|
|
cep->file->filename, cep->line_number);
|
|
errors++;
|
|
}
|
|
}
|
|
else if (!strcmp(cep->name, "rate-limit"))
|
|
{
|
|
int count = 0, period = 0;
|
|
if (!config_parse_flood(cep->value, &count, &period))
|
|
{
|
|
config_error("%s:%i: spamreport::rate-limit: invalid format, must be count:time.",
|
|
cep->file->filename, cep->line_number);
|
|
errors++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
config_error_unknown(cep->file->filename, cep->line_number,
|
|
"spamreport", cep->name);
|
|
errors++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!has_type)
|
|
{
|
|
config_error_missing(ce->file->filename, ce->line_number, "spamreport::type");
|
|
errors++;
|
|
}
|
|
|
|
if (has_type == SPAMREPORT_TYPE_CENTRAL_SPAMREPORT)
|
|
{
|
|
/* Nothing required */
|
|
} else
|
|
if (has_type == SPAMREPORT_TYPE_DRONEBL)
|
|
{
|
|
if (!has_dronebl_rpckey || !has_dronebl_type)
|
|
{
|
|
config_error("%s:%i: spamreport: type dronebl used, missing spamreport::parameters: rpckey and/or type",
|
|
ce->file->filename, ce->line_number);
|
|
errors++;
|
|
}
|
|
} else
|
|
{
|
|
if (!has_url)
|
|
{
|
|
config_error_missing(ce->file->filename, ce->line_number, "spamreport::url");
|
|
errors++;
|
|
}
|
|
|
|
if (!has_http_method)
|
|
{
|
|
config_error_missing(ce->file->filename, ce->line_number, "spamreport::http-method");
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
*errs = errors;
|
|
return errors ? -1 : 1;
|
|
}
|
|
|
|
/** Process a spamreport { } block in the configuration file */
|
|
int tkl_config_run_spamreport(ConfigFile *cf, ConfigEntry *ce, int type)
|
|
{
|
|
ConfigEntry *cep;
|
|
ConfigEntry *cepp;
|
|
Spamreport *s;
|
|
|
|
/* We are only interested in spamreport { } blocks */
|
|
if ((type != CONFIG_MAIN) || strcmp(ce->name, "spamreport"))
|
|
return 0;
|
|
|
|
if (find_spamreport_block(ce->value))
|
|
{
|
|
config_error("%s:%d: spamreport block '%s' already exists, this duplicate one is ignored.",
|
|
ce->file->filename, ce->line_number, ce->value);
|
|
return 1;
|
|
}
|
|
|
|
s = safe_alloc(sizeof(Spamreport));
|
|
safe_strdup(s->name, ce->value);
|
|
|
|
for (cep = ce->items; cep; cep = cep->next)
|
|
{
|
|
if (!strcmp(cep->name, "url"))
|
|
{
|
|
safe_strdup(s->url, cep->value);
|
|
}
|
|
else if (!strcmp(cep->name, "type"))
|
|
{
|
|
s->type = parse_spamreport_type(cep->value);
|
|
|
|
if ((s->type == SPAMREPORT_TYPE_CENTRAL_SPAMREPORT) &&
|
|
!is_module_loaded("central-blocklist"))
|
|
{
|
|
config_warn("%s:%d: blacklist block with type 'central-spamreport' but the 'central-blocklist' module is not loaded.",
|
|
ce->file->filename, ce->line_number);
|
|
}
|
|
}
|
|
else if (!strcmp(cep->name, "http-method"))
|
|
{
|
|
if (!strcmp(cep->value, "get"))
|
|
s->http_method = HTTP_METHOD_GET;
|
|
else if (!strcmp(cep->value, "post"))
|
|
s->http_method = HTTP_METHOD_POST;
|
|
}
|
|
else if (!strcmp(cep->name, "rate-limit"))
|
|
{
|
|
config_parse_flood(cep->value, &s->rate_limit_count, &s->rate_limit_period);
|
|
}
|
|
else if (!strcmp(cep->name, "parameters"))
|
|
{
|
|
for (cepp = cep->items; cepp; cepp = cepp->next)
|
|
{
|
|
if (!strcmp(cepp->name, "staging"))
|
|
{
|
|
if (cepp->value && config_checkval(cepp->value, CFG_YESNO)==0)
|
|
continue; /* skip on 'staging no;' */
|
|
}
|
|
add_nvplist(&s->parameters, 0, cepp->name, cepp->value);
|
|
}
|
|
}
|
|
else if (!strcmp(cep->name, "except"))
|
|
{
|
|
conf_match_block(cf, cep, &s->except);
|
|
}
|
|
}
|
|
|
|
if (s->type == SPAMREPORT_TYPE_DRONEBL)
|
|
s->http_method = HTTP_METHOD_POST;
|
|
|
|
AddListItem(s, spamreports);
|
|
return 1;
|
|
}
|
|
|
|
void free_spamreport_block(Spamreport *s)
|
|
{
|
|
DelListItem(s, spamreports);
|
|
safe_free(s->name);
|
|
safe_free(s->url);
|
|
safe_free_nvplist(s->parameters);
|
|
free_security_group(s->except);
|
|
safe_free(s);
|
|
}
|
|
|
|
void free_spamreport_blocks(void)
|
|
{
|
|
Spamreport *s, *s_next;
|
|
for (s = spamreports; s; s = s_next)
|
|
{
|
|
s_next = s->next;
|
|
free_spamreport_block(s);
|
|
}
|
|
spamreports = NULL;
|
|
}
|
|
|
|
Spamreport *find_spamreport_block(const char *name)
|
|
{
|
|
Spamreport *s;
|
|
|
|
for (s = spamreports; s; s = s->next)
|
|
if (!strcmp(s->name, name))
|
|
return s;
|
|
|
|
return NULL;
|
|
}
|
|
|
|
SpamreportType parse_spamreport_type(const char *s)
|
|
{
|
|
if (!strcmp(s, "simple"))
|
|
return SPAMREPORT_TYPE_SIMPLE;
|
|
else if (!strcmp(s, "dronebl"))
|
|
return SPAMREPORT_TYPE_DRONEBL;
|
|
else if (!strcmp(s, "central-spamreport"))
|
|
return SPAMREPORT_TYPE_CENTRAL_SPAMREPORT;
|
|
return 0;
|
|
}
|
|
|
|
/* Returns 1 if ratelimited (don't do the request), otherwise 0 */
|
|
int spamfilter_block_rate_limited(Spamreport *spamreport)
|
|
{
|
|
SpamreportCounter *s;
|
|
|
|
/* First for the case where there is no rate limit configured... */
|
|
if (spamreport->rate_limit_count == 0)
|
|
return 0;
|
|
|
|
/* First find the block (allocate a new one if not found) */
|
|
for (s = spamreportcounters; s; s = s->next)
|
|
if (!strcmp(s->name, spamreport->name))
|
|
break;
|
|
if (s == NULL)
|
|
{
|
|
s = safe_alloc(sizeof(SpamreportCounter));
|
|
safe_strdup(s->name, spamreport->name);
|
|
AddListItem(s, spamreportcounters);
|
|
}
|
|
|
|
/* Now do the flood check */
|
|
if (s->rate_limit_time + spamreport->rate_limit_period <= TStime())
|
|
{
|
|
/* Time exceeded, reset */
|
|
s->rate_limit_count = 0;
|
|
s->rate_limit_time = TStime();
|
|
}
|
|
if (s->rate_limit_count <= spamreport->rate_limit_count)
|
|
s->rate_limit_count++;
|
|
if (s->rate_limit_count > spamreport->rate_limit_count)
|
|
{
|
|
if (s->last_warning_sent + SPAMREPORT_RATE_LIMIT_WARNING_EVERY < TStime())
|
|
{
|
|
unreal_log(ULOG_WARNING, "spamreport", "SPAMREPORT_RATE_LIMIT", NULL,
|
|
"[spamreport] Rate limit of $rate_limit_count:$rate_limit_period hit "
|
|
"for block $spamreport_block -- further requests dropped (throttled).",
|
|
log_data_integer("rate_limit_count", spamreport->rate_limit_count),
|
|
log_data_integer("rate_limit_period", spamreport->rate_limit_period),
|
|
log_data_string("spamreport_block", spamreport->name));
|
|
s->last_warning_sent = TStime();
|
|
}
|
|
return 1; /* Limit exceeded */
|
|
}
|
|
return 0; /* All OK */
|
|
}
|
|
|
|
/** Return 1 if there is a spamreport { type central-spamreport; } block */
|
|
int _central_spamreport_enabled(void)
|
|
{
|
|
Spamreport *s;
|
|
for (s = spamreports; s; s = s->next)
|
|
if (s->type == SPAMREPORT_TYPE_CENTRAL_SPAMREPORT)
|
|
return 1;
|
|
return 0;
|
|
}
|
|
|
|
int _spamreport(Client *client, const char *ip, NameValuePrioList *details, const char *spamreport_block, Client *by)
|
|
{
|
|
Spamreport *s;
|
|
OutgoingWebRequest *request;
|
|
char urlbuf[512];
|
|
char bodybuf[512];
|
|
char *url = NULL;
|
|
char *body = NULL;
|
|
NameValuePrioList *headers = NULL;
|
|
int num;
|
|
|
|
num = downloads_in_progress();
|
|
if (num > 100)
|
|
{
|
|
// TODO: throttle this error
|
|
unreal_log(ULOG_WARNING, "spamreport", "SPAMREPORT_TOO_MANY_CONCURRENT_REQUESTS", NULL,
|
|
"Already $num_requests HTTP(S) requests in progress, new spamreport requests ignored.",
|
|
log_data_integer("num_requests", num));
|
|
return 0;
|
|
}
|
|
|
|
if (!spamreport_block)
|
|
{
|
|
int ret = 0;
|
|
for (s = spamreports; s; s = s->next)
|
|
ret += spamreport(client, ip, details, s->name, by);
|
|
return ret;
|
|
}
|
|
|
|
s = find_spamreport_block(spamreport_block);
|
|
if (!s)
|
|
return 0; /* NOTFOUND */
|
|
|
|
if (s->except && client && user_allowed_by_security_group(client, s->except))
|
|
return 0;
|
|
// NOTE: 'except' is bypassed for manual SPAMREPORT with an ip and no client.
|
|
|
|
if (spamfilter_block_rate_limited(s))
|
|
return 0; /* spamreport::rate-limit exceeded */
|
|
|
|
if (s->type == SPAMREPORT_TYPE_SIMPLE)
|
|
{
|
|
NameValuePrioList *list = NULL;
|
|
list = duplicate_nvplist(details);
|
|
add_nvplist(&list, -1, "ip", ip);
|
|
buildvarstring_nvp(s->url, urlbuf, sizeof(urlbuf), list, BUILDVARSTRING_URLENCODE|BUILDVARSTRING_UNKNOWN_VAR_IS_EMPTY);
|
|
url = urlbuf;
|
|
safe_free_nvplist(list);
|
|
if (s->http_method == HTTP_METHOD_POST)
|
|
{
|
|
body = strchr(url, '?');
|
|
if (body)
|
|
*body++ = '\0';
|
|
}
|
|
} else
|
|
if (s->type == SPAMREPORT_TYPE_DRONEBL)
|
|
{
|
|
NameValuePrioList *list = NULL;
|
|
NameValuePrioList *list2 = NULL;
|
|
char fmtstring[512];
|
|
url = "https://dronebl.org/rpc2";
|
|
list = duplicate_nvplist(details);
|
|
duplicate_nvplist_append(s->parameters, &list);
|
|
add_nvplist(&list, -1, "ip", ip);
|
|
snprintf(fmtstring, sizeof(fmtstring),
|
|
"<?xml version='1.0'?>\n"
|
|
"<request key='$rpckey'%s>\n"
|
|
" <add ip='$ip' type='$type' comment='$comment'>\n"
|
|
"</request>\n",
|
|
find_nvplist(s->parameters, "staging") ? " staging='1'" : "");
|
|
buildvarstring_nvp(fmtstring, bodybuf, sizeof(bodybuf), list, BUILDVARSTRING_XML|BUILDVARSTRING_UNKNOWN_VAR_IS_EMPTY);
|
|
body = bodybuf;
|
|
safe_free_nvplist(list); // frees all the duplicated lists
|
|
add_nvplist(&headers, 0, "Content-Type", "text/xml");
|
|
} else
|
|
if (s->type == SPAMREPORT_TYPE_CENTRAL_SPAMREPORT)
|
|
{
|
|
return central_spamreport(client, by);
|
|
} else
|
|
{
|
|
abort();
|
|
}
|
|
|
|
#ifdef DEBUGMODE
|
|
unreal_log(ULOG_DEBUG, "spamreport", "SPAMREPORT_SEND_REQUEST", NULL,
|
|
"Calling url '$url' with body '$body'",
|
|
log_data_string("url", url),
|
|
log_data_string("body", (body ? body : "")));
|
|
#endif
|
|
/* Do the web request */
|
|
request = safe_alloc(sizeof(OutgoingWebRequest));
|
|
safe_strdup(request->url, url);
|
|
request->http_method = s->http_method;
|
|
safe_strdup(request->body, body);
|
|
request->headers = headers;
|
|
request->callback = download_complete_dontcare;
|
|
request->max_redirects = 3;
|
|
url_start_async(request);
|
|
return 1;
|
|
}
|
|
|
|
CMD_FUNC(cmd_spamreport)
|
|
{
|
|
Spamreport *to = NULL; /* default is NULL, meaning: all */
|
|
Client *target = NULL;
|
|
const char *ip;
|
|
int n;
|
|
|
|
if (!ValidatePermissionsForPath("server-ban:spamreport",client,NULL,NULL,NULL))
|
|
{
|
|
sendnumeric(client, ERR_NOPRIVILEGES);
|
|
return;
|
|
}
|
|
|
|
if (parc < 2)
|
|
{
|
|
sendnumeric(client, ERR_NEEDMOREPARAMS, "SPAMREPORT");
|
|
return;
|
|
}
|
|
|
|
ip = parv[1];
|
|
|
|
if ((target = find_user(parv[1], NULL)))
|
|
{
|
|
if (!MyUser(target))
|
|
{
|
|
/* Forward it to other server */
|
|
if (parc > 2)
|
|
{
|
|
sendto_one(target, NULL, ":%s SPAMREPORT %s %s",
|
|
client->id, parv[1], parv[2]);
|
|
} else {
|
|
sendto_one(target, NULL, ":%s SPAMREPORT %s",
|
|
client->id, parv[1]);
|
|
}
|
|
return;
|
|
}
|
|
/* It's for us */
|
|
if (target->ip)
|
|
ip = target->ip;
|
|
}
|
|
|
|
if (!is_valid_ip(ip))
|
|
{
|
|
sendnotice(client, "Not a valid nick/IP: %s", ip);
|
|
return;
|
|
}
|
|
|
|
if ((parc > 2) && !BadPtr(parv[2]))
|
|
{
|
|
to = find_spamreport_block(parv[2]);
|
|
if (!to)
|
|
{
|
|
sendnotice(client, "Could not find spamreport block '%s'", parv[2]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!((n = spamreport(target, ip, NULL, to ? to->name : NULL, client))))
|
|
sendnotice(client, "Could not report spam. No spamreport { } blocks configured, or all filtered out/exempt.");
|
|
else
|
|
sendnotice(client, "Sending spam report to %d target(s)", n);
|
|
}
|
|
|
|
void spamreportcounters_free_all(ModData *m)
|
|
{
|
|
SpamreportCounter *s, *s_next;
|
|
for (s = spamreportcounters; s; s = s_next)
|
|
{
|
|
s_next = s->next;
|
|
safe_free(s->name);
|
|
safe_free(s);
|
|
}
|
|
}
|