1
0
mirror of https://github.com/unrealircd/unrealircd.git synced 2026-07-10 11:23:12 +02:00
Files
unrealircd/src/modules/chathistory.c
T
Bram Matthys 36baf946a3 Guard against multiline+history amplification attacks in CHATHISTORY.
Add a little fake lag based on history result: 400ms for 50 lines
under normal conditions where 50 lines = 50 lines. But this can go
up to 5000ms for worst-case amplification attacks where requesting
50 lines actually returns 50*15=750 lines when each line is a multiline
with max-lines, which gets you close to 350k+. This would only happen
if someone on the channel is doing evil stuff (with presumably consent
of the ops).

Also guard against hiting max sendq. If we are too close, then we
reject the CHATHISTORY request rather than quiting with "Max SendQ
exceeded". This protects against an attack where someone would be
tricked into joining a channel with amplified history (as explained
in previous paragraph), their client would do an automatic CHATHISTORY
request and then the victim would exceed max sendq and thus be killed.

And yes, this and maaaaany other multiline + history interactions
and many "buts" and security/flood concerns are why this implemtnation
took (and still takes) a lot of hours to get right :D.
2026-04-03 07:59:11 +02:00

431 lines
11 KiB
C

/* src/modules/chathistory.c - IRCv3 CHATHISTORY command.
* (C) Copyright 2021 Bram Matthys (Syzop) and the UnrealIRCd team
* License: GPLv2 or later
*
* This implements the "CHATHISTORY" command, the CAP and 005 token.
* https://ircv3.net/specs/extensions/chathistory
*/
#include "unrealircd.h"
ModuleHeader MOD_HEADER
= {
"chathistory",
"1.0",
"IRCv3 CHATHISTORY command",
"UnrealIRCd Team",
"unrealircd-6",
};
/* Structs */
typedef struct ChatHistoryTarget ChatHistoryTarget;
struct ChatHistoryTarget {
ChatHistoryTarget *prev, *next;
char *datetime;
char *object;
};
/* Forward declarations */
CMD_FUNC(cmd_chathistory);
/* Global variables */
long CAP_CHATHISTORY = 0L;
#define CHATHISTORY_LIMIT 50
MOD_INIT()
{
ClientCapabilityInfo c;
MARK_AS_OFFICIAL_MODULE(modinfo);
CommandAdd(modinfo->handle, "CHATHISTORY", cmd_chathistory, MAXPARA, CMD_USER);
memset(&c, 0, sizeof(c));
c.name = "draft/chathistory";
ClientCapabilityAdd(modinfo->handle, &c, &CAP_CHATHISTORY);
return MOD_SUCCESS;
}
MOD_LOAD()
{
ISupportSetFmt(modinfo->handle, "CHATHISTORY", "%d", CHATHISTORY_LIMIT);
ISupportSetFmt(modinfo->handle, "MSGREFTYPES", "msgid,timestamp");
return MOD_SUCCESS;
}
MOD_UNLOAD()
{
return MOD_SUCCESS;
}
int chathistory_token(const char *str, char *token, char **store)
{
char request[BUFSIZE];
char *p;
strlcpy(request, str, sizeof(request));
p = strchr(request, '=');
if (!p)
return 0;
*p = '\0'; // frag
if (!strcmp(request, token))
{
*p = '='; // restore
*store = strdup(p + 1); // can be \0
return 1;
}
*p = '='; // restore
return 0;
}
static void add_chathistory_target_list(ChatHistoryTarget *new, ChatHistoryTarget **list)
{
ChatHistoryTarget *x, *last = NULL;
if (!*list)
{
/* We are the only item. Easy. */
*list = new;
return;
}
for (x = *list; x; x = x->next)
{
last = x;
if (strcmp(new->datetime, x->datetime) >= 0)
break;
}
if (x)
{
if (x->prev)
{
/* We will insert ourselves just before this item */
new->prev = x->prev;
new->next = x;
x->prev->next = new;
x->prev = new;
} else {
/* We are the new head */
*list = new;
new->next = x;
x->prev = new;
}
} else
{
/* We are the last item */
last->next = new;
new->prev = last;
}
}
static void add_chathistory_target(ChatHistoryTarget **list, HistoryResult *r)
{
MessageTag *m;
time_t ts;
char *datetime;
ChatHistoryTarget *e;
if (!r->log || !((m = find_mtag(r->log->mtags, "time"))) || !m->value)
return;
datetime = m->value;
e = safe_alloc(sizeof(ChatHistoryTarget));
safe_strdup(e->datetime, datetime);
safe_strdup(e->object, r->object);
add_chathistory_target_list(e, list);
}
static void chathistory_targets_send_line(Client *client, ChatHistoryTarget *r, char *batchid)
{
MessageTag *mtags = NULL;
MessageTag *m;
if (!BadPtr(batchid))
{
mtags = safe_alloc(sizeof(MessageTag));
mtags->name = strdup("batch");
mtags->value = strdup(batchid);
}
sendto_one(client, mtags, ":%s CHATHISTORY TARGETS %s %s",
me.name, r->object, r->datetime);
if (mtags)
free_message_tags(mtags);
}
void chathistory_targets(Client *client, HistoryFilter *filter, int limit)
{
Membership *mp;
HistoryResult *r;
char batch[BATCHLEN+1];
int sent = 0;
ChatHistoryTarget *targets = NULL, *targets_next;
/* 1. Grab all information we need */
filter->cmd = HFC_BEFORE;
if (strcmp(filter->timestamp_a, filter->timestamp_b) < 0)
{
/* Swap if needed */
char *swap = filter->timestamp_a;
filter->timestamp_a = filter->timestamp_b;
filter->timestamp_b = swap;
}
filter->limit = 1;
for (mp = client->user->channel; mp; mp = mp->next)
{
Channel *channel = mp->channel;
r = history_request(channel->name, filter);
if (r)
{
add_chathistory_target(&targets, r);
free_history_result(r);
}
}
/* 2. Now send it to the client */
batch[0] = '\0';
if (HasCapability(client, "batch"))
{
/* Start a new batch */
generate_batch_id(batch);
sendto_one(client, NULL, ":%s BATCH +%s draft/chathistory-targets", me.name, batch);
}
for (; targets; targets = targets_next)
{
targets_next = targets->next;
if (++sent < limit)
chathistory_targets_send_line(client, targets, batch);
safe_free(targets->datetime);
safe_free(targets->object);
safe_free(targets);
}
/* End of batch */
if (*batch)
sendto_one(client, NULL, ":%s BATCH -%s", me.name, batch);
}
void send_empty_batch(Client *client, const char *target)
{
char batch[BATCHLEN+1];
if (HasCapability(client, "batch"))
{
generate_batch_id(batch);
sendto_one(client, NULL, ":%s BATCH +%s chathistory %s", me.name, batch, target);
sendto_one(client, NULL, ":%s BATCH -%s", me.name, batch);
}
}
CMD_FUNC(cmd_chathistory)
{
HistoryFilter *filter = NULL;
HistoryResult *r = NULL;
Channel *channel;
memset(&filter, 0, sizeof(filter));
/* This command is only for local users */
if (!MyUser(client))
return;
if ((parc < 5) || BadPtr(parv[4]))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS :Insufficient parameters", me.name);
return;
}
if (!HasCapability(client, "server-time"))
{
sendnotice(client, "Your IRC client does not support the 'server-time' capability");
sendnotice(client, "https://ircv3.net/specs/extensions/server-time");
sendnotice(client, "History request refused.");
return;
}
if (!strcasecmp(parv[1], "TARGETS"))
{
Membership *mp;
int limit;
filter = safe_alloc(sizeof(HistoryFilter));
/* Below this point, instead of 'return', use 'goto end' */
if (!chathistory_token(parv[2], "timestamp", &filter->timestamp_a))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx",
me.name, parv[1], parv[3]);
goto end;
}
if (!chathistory_token(parv[3], "timestamp", &filter->timestamp_b))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx",
me.name, parv[1], parv[4]);
goto end;
}
limit = atoi(parv[4]);
chathistory_targets(client, filter, limit);
goto end;
}
/* We don't support retrieving chathistory for PM's. Send empty response/batch, similar to channels without +H. */
if (parv[2][0] != '#')
{
send_empty_batch(client, parv[2]);
return;
}
channel = find_channel(parv[2]);
if (!channel)
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_TARGET %s %s :Messages could not be retrieved, not an existing channel",
me.name, parv[1], parv[2]);
return;
}
if (!IsMember(client, channel))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_TARGET %s %s :Messages could not be retrieved, you are not a member",
me.name, parv[1], parv[2]);
return;
}
/* Channel is not +H? Send empty response/batch (as per IRCv3 discussion) */
if (!has_channel_mode(channel, 'H'))
{
send_empty_batch(client, channel->name);
return;
}
filter = safe_alloc(sizeof(HistoryFilter));
/* Below this point, instead of 'return', use 'goto end', which takes care of the freeing of 'filter' and 'history' */
if (!strcasecmp(parv[1], "BEFORE"))
{
filter->cmd = HFC_BEFORE;
if (!chathistory_token(parv[3], "timestamp", &filter->timestamp_a) &&
!chathistory_token(parv[3], "msgid", &filter->msgid_a))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx or msgid=xxx",
me.name, parv[1], parv[3]);
goto end;
}
filter->limit = atoi(parv[4]);
} else
if (!strcasecmp(parv[1], "AFTER"))
{
filter->cmd = HFC_AFTER;
if (!chathistory_token(parv[3], "timestamp", &filter->timestamp_a) &&
!chathistory_token(parv[3], "msgid", &filter->msgid_a))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx or msgid=xxx",
me.name, parv[1], parv[3]);
goto end;
}
filter->limit = atoi(parv[4]);
} else
if (!strcasecmp(parv[1], "LATEST"))
{
filter->cmd = HFC_LATEST;
if (!chathistory_token(parv[3], "timestamp", &filter->timestamp_a) &&
!chathistory_token(parv[3], "msgid", &filter->msgid_a) &&
strcmp(parv[3], "*"))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx or msgid=xxx or *",
me.name, parv[1], parv[3]);
goto end;
}
filter->limit = atoi(parv[4]);
} else
if (!strcasecmp(parv[1], "AROUND"))
{
filter->cmd = HFC_AROUND;
if (!chathistory_token(parv[3], "timestamp", &filter->timestamp_a) &&
!chathistory_token(parv[3], "msgid", &filter->msgid_a))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx or msgid=xxx",
me.name, parv[1], parv[3]);
goto end;
}
filter->limit = atoi(parv[4]);
} else
if (!strcasecmp(parv[1], "BETWEEN"))
{
filter->cmd = HFC_BETWEEN;
if (BadPtr(parv[5]))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s :Insufficient parameters", parv[1], me.name);
goto end;
}
if (!chathistory_token(parv[3], "timestamp", &filter->timestamp_a) &&
!chathistory_token(parv[3], "msgid", &filter->msgid_a))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx or msgid=xxx",
me.name, parv[1], parv[3]);
goto end;
}
if (!chathistory_token(parv[4], "timestamp", &filter->timestamp_b) &&
!chathistory_token(parv[4], "msgid", &filter->msgid_b))
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %s :Invalid parameter, must be timestamp=xxx or msgid=xxx",
me.name, parv[1], parv[4]);
goto end;
}
filter->limit = atoi(parv[5]);
} else {
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s :Invalid subcommand", me.name, parv[1]);
goto end;
}
if (filter->limit <= 0)
{
sendto_one(client, NULL, ":%s FAIL CHATHISTORY INVALID_PARAMS %s %d :Specified limit is =<0",
me.name, parv[1], filter->limit);
goto end;
}
if (filter->limit > CHATHISTORY_LIMIT)
filter->limit = CHATHISTORY_LIMIT;
if ((r = history_request(channel->name, filter)))
{
if ((r->num_bytes*1.5) > get_sendq(client))
{
/* This would get rather close to max sendq. We reject
* the request as to not cause "Max SendQ exceeded".
*/
sendto_one(client, NULL, ":%s FAIL CHATHISTORY MESSAGE_ERROR %s %s :History too large to deliver",
me.name, parv[1], channel->name);
} else {
/* All ok, bump fake lag and add the result.
* The fakelag bump under normal circumstances is tiny
* at like 400ms worst-case without multiline. However, it
* becomes a different story if nearly all lines are multilines
* and we get into a "worst case scenario" with amplifications
* where the max 50 becomes 50*15=750 lines in practice.
* In such a case we may bump up to 5000ms max. This should
* not happen under normal circumstances and only when someone
* with lots of time on their hands is doing something evil
* in that channel (with apparent consent of the ops).
*/
long fakelag_ms = r->num_bytes / 64;
if (fakelag_ms > 5000)
fakelag_ms = 5000;
add_fake_lag(client, fakelag_ms);
history_send_result(client, r);
}
}
end:
if (filter)
free_history_filter(filter);
if (r)
free_history_result(r);
}