Compare commits

..

1 Commits

Author SHA1 Message Date
6f8832377a fix(flatfile): harden flat-file backend against injection and traversal attacks
Some checks failed
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Linux (debian) (pull_request) Successful in 6m14s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m17s
CI Code / Linux (arch) (pull_request) Successful in 6m27s
CI Code / Code Coverage (pull_request) Has been cancelled
Security fixes for 7 vulnerabilities in database_flatfile.c:

1. Path traversal via crafted JID (HIGH):
   _ff_jid_to_dir() now strips '/', '\' -> '_' and collapses '..' -> '__',
   preventing a malicious federated JID from escaping the log directory.

2. Log injection via unescaped message body (HIGH):
   Add _ff_escape_message()/_ff_unescape_message() -- escape \n, \r, \\
   in message text on write, unescape on read. Prevents remote contacts
   from injecting fake log lines with forged sender/timestamp/encryption.

3. Metadata field injection (HIGH):
   Add _ff_escape_meta_value()/_ff_unescape_meta_value() -- escape |, ],
   \\, \n, \r in stanza_id/archive_id/replace_id. Parser uses escape-aware
   _ff_find_unescaped_char() and _ff_split_meta() instead of strchr/strsplit.

4. Sender ": " parsing confusion (MEDIUM):
   Escape ": " -> "\: " in XMPP resource on write. Parser uses
   _ff_find_unescaped_colonspace() + _ff_unescape_sender_resource().

5. LMC correction chain cycle -> infinite loop (MEDIUM):
   Add visited hash-set and FF_MAX_LMC_DEPTH (100) limit to chain walker.

6. Unbounded getline() -> OOM (MEDIUM):
   Add FF_MAX_LINE_LEN (10 MB) cap in _ff_readline(); overlength lines
   are skipped with a warning. Replaces all char buf[8192]/fgets() sites
   with dynamic getline() + truncated-line detection at EOF.

7. Symlink attack + TOCTOU on file creation (MEDIUM):
   Replace fopen("a") with open(O_WRONLY|O_APPEND|O_CREAT|O_EXCL|O_NOFOLLOW)
   + fdopen() -- atomic new-file detection, symlink rejection via O_NOFOLLOW.
   _ff_ensure_dir() checks g_lstat() for symlinks. File permissions 0600
   set via open() mode, not post-hoc g_chmod().
2026-02-17 19:53:15 +03:00
2 changed files with 168 additions and 78 deletions

View File

@@ -56,7 +56,8 @@ typedef enum {
} integrity_level_t;
// A single integrity issue found during verification
typedef struct {
typedef struct
{
integrity_level_t level;
char* file;
int line;
@@ -66,7 +67,8 @@ typedef struct {
void integrity_issue_free(integrity_issue_t* issue);
// Backend vtable: pluggable storage backends implement this interface
typedef struct db_backend_t {
typedef struct db_backend_t
{
const char* name;
gboolean (*init)(ProfAccount* account);
void (*close)(void);

View File

@@ -42,9 +42,9 @@
#include "xmpp/xmpp.h"
#include "xmpp/message.h"
#define DIR_FLATLOG "flatlog"
#define FLATFILE_HEADER "# profanity chat log — UTF-8, LF line endings\n# vim: set fileencoding=utf-8 fileformat=unix :\n"
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
#define DIR_FLATLOG "flatlog"
#define FLATFILE_HEADER "# profanity chat log — UTF-8, LF line endings\n# vim: set fileencoding=utf-8 fileformat=unix :\n"
#define FF_MAX_LINE_LEN (10 * 1024 * 1024) /* 10 MB — reject lines longer than this */
#define FF_MAX_LMC_DEPTH 100 /* max correction chain depth */
// Account JID stored during init for path construction
@@ -124,12 +124,14 @@ _ff_get_message_enc_type(const char* const encstr)
static char*
_ff_jid_to_dir(const char* jid)
{
if (!jid || jid[0] == '\0') return NULL;
if (!jid || jid[0] == '\0')
return NULL;
// Reject embedded null bytes (strlen already stops, but be explicit)
// Replace '@' first
char* step1 = str_replace(jid, "@", "_at_");
if (!step1) return NULL;
if (!step1)
return NULL;
// Replace '/' and '\\' with '_' to prevent path traversal
GString* out = g_string_sized_new(strlen(step1));
@@ -163,7 +165,8 @@ _ff_jid_to_dir(const char* jid)
static char*
_ff_get_contact_dir(const char* contact_barejid)
{
if (!g_flatfile_account_jid || !contact_barejid) return NULL;
if (!g_flatfile_account_jid || !contact_barejid)
return NULL;
auto_gchar gchar* data_path = files_get_data_path(DIR_FLATLOG);
auto_gchar gchar* my_dir = _ff_jid_to_dir(g_flatfile_account_jid);
@@ -179,7 +182,8 @@ static char*
_ff_get_log_path(const char* contact_barejid, GDateTime* dt)
{
auto_gchar gchar* contact_dir = _ff_get_contact_dir(contact_barejid);
if (!contact_dir) return NULL;
if (!contact_dir)
return NULL;
auto_gchar gchar* date_str = g_date_time_format(dt, "%Y_%m_%d");
char* result = g_strdup_printf("%s/%s.log", contact_dir, date_str);
@@ -218,14 +222,23 @@ _ff_ensure_dir(const char* path)
static char*
_ff_escape_message(const char* text)
{
if (!text) return g_strdup("");
if (!text)
return g_strdup("");
GString* out = g_string_sized_new(strlen(text));
for (const char* p = text; *p; p++) {
switch (*p) {
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
default: g_string_append_c(out, *p); break;
case '\\':
g_string_append(out, "\\\\");
break;
case '\n':
g_string_append(out, "\\n");
break;
case '\r':
g_string_append(out, "\\r");
break;
default:
g_string_append_c(out, *p);
break;
}
}
return g_string_free(out, FALSE);
@@ -235,15 +248,22 @@ _ff_escape_message(const char* text)
static char*
_ff_unescape_message(const char* text)
{
if (!text) return g_strdup("");
if (!text)
return g_strdup("");
GString* out = g_string_sized_new(strlen(text));
for (const char* p = text; *p; p++) {
if (*p == '\\' && *(p + 1)) {
p++;
switch (*p) {
case '\\': g_string_append_c(out, '\\'); break;
case 'n': g_string_append_c(out, '\n'); break;
case 'r': g_string_append_c(out, '\r'); break;
case '\\':
g_string_append_c(out, '\\');
break;
case 'n':
g_string_append_c(out, '\n');
break;
case 'r':
g_string_append_c(out, '\r');
break;
default:
g_string_append_c(out, '\\');
g_string_append_c(out, *p);
@@ -261,16 +281,29 @@ _ff_unescape_message(const char* text)
static char*
_ff_escape_meta_value(const char* val)
{
if (!val || strlen(val) == 0) return NULL;
if (!val || strlen(val) == 0)
return NULL;
GString* out = g_string_sized_new(strlen(val));
for (const char* p = val; *p; p++) {
switch (*p) {
case '|': g_string_append(out, "\\|"); break;
case ']': g_string_append(out, "\\]"); break;
case '\\': g_string_append(out, "\\\\"); break;
case '\n': g_string_append(out, "\\n"); break;
case '\r': g_string_append(out, "\\r"); break;
default: g_string_append_c(out, *p); break;
case '|':
g_string_append(out, "\\|");
break;
case ']':
g_string_append(out, "\\]");
break;
case '\\':
g_string_append(out, "\\\\");
break;
case '\n':
g_string_append(out, "\\n");
break;
case '\r':
g_string_append(out, "\\r");
break;
default:
g_string_append_c(out, *p);
break;
}
}
return g_string_free(out, FALSE);
@@ -280,17 +313,28 @@ _ff_escape_meta_value(const char* val)
static char*
_ff_unescape_meta_value(const char* val)
{
if (!val) return NULL;
if (!val)
return NULL;
GString* out = g_string_sized_new(strlen(val));
for (const char* p = val; *p; p++) {
if (*p == '\\' && *(p + 1)) {
p++;
switch (*p) {
case '|': g_string_append_c(out, '|'); break;
case ']': g_string_append_c(out, ']'); break;
case '\\': g_string_append_c(out, '\\'); break;
case 'n': g_string_append_c(out, '\n'); break;
case 'r': g_string_append_c(out, '\r'); break;
case '|':
g_string_append_c(out, '|');
break;
case ']':
g_string_append_c(out, ']');
break;
case '\\':
g_string_append_c(out, '\\');
break;
case 'n':
g_string_append_c(out, '\n');
break;
case 'r':
g_string_append_c(out, '\r');
break;
default:
g_string_append_c(out, '\\');
g_string_append_c(out, *p);
@@ -330,16 +374,19 @@ _ff_readline(FILE* fp, gboolean* truncated)
int ch;
while ((ch = fgetc(fp)) != EOF && ch != '\n') {}
}
if (truncated) *truncated = FALSE;
if (truncated)
*truncated = FALSE;
// Return empty string so caller's loop continues (parse will reject it)
return g_strdup("");
}
if (truncated) *truncated = FALSE;
if (truncated)
*truncated = FALSE;
if (nread > 0 && line[nread - 1] == '\n') {
line[--nread] = '\0';
} else if (feof(fp)) {
// Line without trailing newline at EOF — likely a partial write
if (truncated) *truncated = TRUE;
if (truncated)
*truncated = TRUE;
}
return line;
}
@@ -428,13 +475,15 @@ _ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* en
static const char*
_ff_find_unescaped_char(const char* str, char ch)
{
if (!str) return NULL;
if (!str)
return NULL;
for (const char* p = str; *p; p++) {
if (*p == '\\' && *(p + 1)) {
p++; // skip escaped character
continue;
}
if (*p == ch) return p;
if (*p == ch)
return p;
}
return NULL;
}
@@ -446,14 +495,15 @@ _ff_split_meta(const char* meta)
{
GPtrArray* arr = g_ptr_array_new();
const char* start = meta;
for (const char* p = meta; ; p++) {
for (const char* p = meta;; p++) {
if (*p == '\\' && *(p + 1)) {
p++; // skip escaped char
continue;
}
if (*p == '|' || *p == '\0') {
g_ptr_array_add(arr, g_strndup(start, p - start));
if (*p == '\0') break;
if (*p == '\0')
break;
start = p + 1;
}
}
@@ -466,7 +516,8 @@ _ff_split_meta(const char* meta)
static const char*
_ff_find_unescaped_colonspace(const char* str)
{
if (!str) return NULL;
if (!str)
return NULL;
for (const char* p = str; *p; p++) {
if (*p == '\\' && *(p + 1)) {
p++; // skip escaped character
@@ -483,7 +534,8 @@ _ff_find_unescaped_colonspace(const char* str)
static char*
_ff_unescape_sender_resource(const char* res)
{
if (!res) return NULL;
if (!res)
return NULL;
GString* out = g_string_sized_new(strlen(res));
for (const char* p = res; *p; p++) {
if (*p == '\\' && *(p + 1)) {
@@ -507,7 +559,8 @@ _ff_unescape_sender_resource(const char* res)
// --- Line parser ---
typedef struct {
typedef struct
{
char* timestamp_str;
GDateTime* timestamp;
char* type;
@@ -523,9 +576,11 @@ typedef struct {
static void
_ff_parsed_line_free(flatfile_parsed_line_t* pl)
{
if (!pl) return;
if (!pl)
return;
g_free(pl->timestamp_str);
if (pl->timestamp) g_date_time_unref(pl->timestamp);
if (pl->timestamp)
g_date_time_unref(pl->timestamp);
g_free(pl->type);
g_free(pl->enc);
g_free(pl->stanza_id);
@@ -570,7 +625,8 @@ _ff_parse_line(const char* line)
g_free(work);
work = converted;
} else {
if (err) g_error_free(err);
if (err)
g_error_free(err);
g_free(work);
return NULL;
}
@@ -616,7 +672,8 @@ _ff_parse_line(const char* line)
// Parse sender: message after '] '
const char* after_meta = bracket_end + 1;
if (*after_meta == ' ') after_meta++;
if (*after_meta == ' ')
after_meta++;
// Find first *unescaped* ': ' which separates sender from message.
// Resources may contain escaped \: sequences.
@@ -685,15 +742,17 @@ _ff_parse_line(const char* line)
// Parse timestamp
result->timestamp = g_date_time_new_from_iso8601(result->timestamp_str, NULL);
if (!result->timestamp) {
log_warning("flatfile: unparseable timestamp: %s", result->timestamp_str);
log_warning("flatfile: unparsable timestamp: %s", result->timestamp_str);
_ff_parsed_line_free(result);
g_free(work);
return NULL;
}
// Default type/enc if missing
if (!result->type) result->type = g_strdup("chat");
if (!result->enc) result->enc = g_strdup("none");
if (!result->type)
result->type = g_strdup("chat");
if (!result->enc)
result->enc = g_strdup("none");
g_free(work);
return result;
@@ -841,7 +900,8 @@ _flatfile_close(void)
static void
_flatfile_add_incoming(ProfMessage* message)
{
if (!message || !message->from_jid) return;
if (!message || !message->from_jid)
return;
const Jid* to_jid = message->to_jid ? message->to_jid : connection_get_jid();
const char* type = _ff_get_message_type_str(message->type);
@@ -894,7 +954,8 @@ _ff_list_log_files(const char* contact_barejid)
}
GDir* dir = g_dir_open(contact_dir, 0, NULL);
if (!dir) return NULL;
if (!dir)
return NULL;
GSList* files = NULL;
const gchar* fname;
@@ -918,7 +979,8 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid,
GSList* messages = NULL;
FILE* fp = fopen(filepath, "r");
if (!fp) return NULL;
if (!fp)
return NULL;
// BOM detection
int c1 = fgetc(fp);
@@ -940,7 +1002,8 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid,
flatfile_parsed_line_t* pl = _ff_parse_line(buf);
free(buf);
if (!pl) continue;
if (!pl)
continue;
// Check timestamp bounds
if (start_time) {
@@ -969,8 +1032,7 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid,
// Check JID filter: message must involve contact and our JID
gboolean matches = FALSE;
if (myjid && myjid->barejid && contact_barejid) {
if ((g_strcmp0(pl->from_jid, contact_barejid) == 0) ||
(g_strcmp0(pl->from_jid, myjid->barejid) == 0)) {
if ((g_strcmp0(pl->from_jid, contact_barejid) == 0) || (g_strcmp0(pl->from_jid, myjid->barejid) == 0)) {
matches = TRUE;
}
} else {
@@ -1098,7 +1160,7 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
GSList* all_parsed = NULL;
for (GSList* f = files; f; f = f->next) {
GSList* file_msgs = _ff_read_file_messages(f->data, contact_barejid,
start_time, effective_end, myjid);
start_time, effective_end, myjid);
all_parsed = g_slist_concat(all_parsed, file_msgs);
}
@@ -1127,7 +1189,8 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
GSList* nth = g_slist_nth(pre_result, MESSAGES_TO_RETRIEVE);
if (nth) {
GSList* prev = g_slist_nth(pre_result, MESSAGES_TO_RETRIEVE - 1);
if (prev) prev->next = NULL;
if (prev)
prev->next = NULL;
g_slist_free_full(nth, (GDestroyNotify)message_free);
}
} else {
@@ -1135,7 +1198,8 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
guint skip = total - MESSAGES_TO_RETRIEVE;
GSList* keep_start = g_slist_nth(pre_result, skip);
GSList* prev = g_slist_nth(pre_result, skip - 1);
if (prev) prev->next = NULL;
if (prev)
prev->next = NULL;
g_slist_free_full(pre_result, (GDestroyNotify)message_free);
pre_result = keep_start;
}
@@ -1156,13 +1220,15 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
ProfMessage* msg = message_init();
if (!g_flatfile_account_jid || !contact_barejid) {
if (is_last) msg->timestamp = g_date_time_new_now_utc();
if (is_last)
msg->timestamp = g_date_time_new_now_utc();
return msg;
}
GSList* files = _ff_list_log_files(contact_barejid);
if (!files) {
if (is_last) msg->timestamp = g_date_time_new_now_utc();
if (is_last)
msg->timestamp = g_date_time_new_now_utc();
return msg;
}
@@ -1174,7 +1240,8 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
FILE* fp = fopen(target_file, "r");
if (!fp) {
g_slist_free_full(files, g_free);
if (is_last) msg->timestamp = g_date_time_new_now_utc();
if (is_last)
msg->timestamp = g_date_time_new_now_utc();
return msg;
}
@@ -1194,7 +1261,8 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
while ((buf = _ff_readline(fp, NULL)) != NULL) {
found = _ff_parse_line(buf);
free(buf);
if (found) break;
if (found)
break;
}
} else {
// Find last valid line — read all and keep last
@@ -1202,7 +1270,8 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last)
flatfile_parsed_line_t* pl = _ff_parse_line(buf);
free(buf);
if (pl) {
if (found) _ff_parsed_line_free(found);
if (found)
_ff_parsed_line_free(found);
found = pl;
}
}
@@ -1280,7 +1349,8 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
const char* cdir_path = cd->data;
GDir* dir = g_dir_open(cdir_path, 0, NULL);
if (!dir) continue;
if (!dir)
continue;
GSList* log_files = NULL;
const gchar* fname;
@@ -1316,7 +1386,8 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
}
FILE* fp = fopen(filepath, "r");
if (!fp) continue;
if (!fp)
continue;
// BOM check (4l)
int c1 = fgetc(fp);
@@ -1352,7 +1423,10 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
}
// Skip empty lines and comments
if (len == 0 || buf[0] == '#') { free(buf); continue; }
if (len == 0 || buf[0] == '#') {
free(buf);
continue;
}
is_empty = FALSE;
// UTF-8 validation (4k/4n)
@@ -1389,7 +1463,7 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
issue->level = INTEGRITY_ERROR;
issue->file = g_strdup(basename);
issue->line = lineno;
issue->message = g_strdup("Unparseable line");
issue->message = g_strdup("Unparsable line");
issues = g_slist_append(issues, issue);
free(buf);
continue;
@@ -1397,8 +1471,10 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
free(buf); // done with raw line
if (!first_ts) first_ts = g_date_time_ref(pl->timestamp);
if (last_ts) g_date_time_unref(last_ts);
if (!first_ts)
first_ts = g_date_time_ref(pl->timestamp);
if (last_ts)
g_date_time_unref(last_ts);
last_ts = g_date_time_ref(pl->timestamp);
// Timestamp order within file (4c)
@@ -1412,7 +1488,8 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
issue->message = g_strdup_printf("Timestamp out of order (%s after %s)", ts_cur, ts_prev);
issues = g_slist_append(issues, issue);
}
if (prev_ts) g_date_time_unref(prev_ts);
if (prev_ts)
g_date_time_unref(prev_ts);
prev_ts = g_date_time_ref(pl->timestamp);
// Duplicate stanza-id / archive-id (4f)
@@ -1486,12 +1563,16 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
}
}
if (prev_file_last_ts) g_date_time_unref(prev_file_last_ts);
if (prev_file_last_ts)
g_date_time_unref(prev_file_last_ts);
prev_file_last_ts = last_ts ? g_date_time_ref(last_ts) : NULL;
if (prev_ts) g_date_time_unref(prev_ts);
if (first_ts) g_date_time_unref(first_ts);
if (last_ts) g_date_time_unref(last_ts);
if (prev_ts)
g_date_time_unref(prev_ts);
if (first_ts)
g_date_time_unref(first_ts);
if (last_ts)
g_date_time_unref(last_ts);
}
// Second pass: check LMC references across all files (4g)
@@ -1501,7 +1582,8 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
basename_lmc = basename_lmc ? basename_lmc + 1 : filepath;
FILE* fp = fopen(filepath, "r");
if (!fp) continue;
if (!fp)
continue;
// Skip BOM
int b1 = fgetc(fp);
@@ -1516,12 +1598,17 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
while ((buf = _ff_readline(fp, NULL)) != NULL) {
lineno++;
gsize len = strlen(buf);
if (len > 0 && buf[len - 1] == '\r') buf[--len] = '\0';
if (len == 0 || buf[0] == '#') { free(buf); continue; }
if (len > 0 && buf[len - 1] == '\r')
buf[--len] = '\0';
if (len == 0 || buf[0] == '#') {
free(buf);
continue;
}
flatfile_parsed_line_t* pl = _ff_parse_line(buf);
free(buf);
if (!pl) continue;
if (!pl)
continue;
if (pl->replace_id && strlen(pl->replace_id) > 0) {
if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) {
@@ -1538,7 +1625,8 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
fclose(fp);
}
if (prev_file_last_ts) g_date_time_unref(prev_file_last_ts);
if (prev_file_last_ts)
g_date_time_unref(prev_file_last_ts);
g_hash_table_destroy(seen_ids);
g_hash_table_destroy(all_stanza_ids);
g_slist_free_full(log_files, g_free);