From 89b90e5cf2b19e55f94f08b43227f5bf50229804 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Tue, 17 Feb 2026 19:35:44 +0300 Subject: [PATCH] fix(flatfile): harden flat-file backend against injection and traversal attacks 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(). --- src/database.h | 6 +- src/database_flatfile.c | 631 +++++++++++++++++++++++++++++++++------- 2 files changed, 529 insertions(+), 108 deletions(-) diff --git a/src/database.h b/src/database.h index 5c24adfc..34b3c515 100644 --- a/src/database.h +++ b/src/database.h @@ -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); diff --git a/src/database_flatfile.c b/src/database_flatfile.c index 0abf18be..decc5172 100644 --- a/src/database_flatfile.c +++ b/src/database_flatfile.c @@ -24,6 +24,8 @@ #include "config.h" #include +#include +#include #include #include #include @@ -40,8 +42,10 @@ #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 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 static char* g_flatfile_account_jid = NULL; @@ -112,12 +116,48 @@ _ff_get_message_enc_type(const char* const encstr) // --- Path helpers --- -// Replace '@' with '_at_' in JID for filesystem-safe directory names +// Sanitise a JID for use as a directory name. +// 1. Replace '@' with '_at_' +// 2. Reject / strip path-separator and traversal characters: '/', '\0', '..' +// This prevents a malicious federated JID like "../../../tmp/pwned" from +// escaping the log directory tree. static char* _ff_jid_to_dir(const char* jid) { - if (!jid) return NULL; - return str_replace(jid, "@", "_at_"); + 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; + + // Replace '/' and '\\' with '_' to prevent path traversal + GString* out = g_string_sized_new(strlen(step1)); + for (const char* p = step1; *p; p++) { + if (*p == '/' || *p == '\\') { + g_string_append_c(out, '_'); + } else { + g_string_append_c(out, *p); + } + } + free(step1); + + // Collapse any remaining ".." sequences to "__" (belt-and-suspenders) + char* result = g_string_free(out, FALSE); + char* dotdot; + while ((dotdot = strstr(result, "..")) != NULL) { + dotdot[0] = '_'; + dotdot[1] = '_'; + } + + // Reject empty result + if (result[0] == '\0') { + g_free(result); + return NULL; + } + return result; } // Get the base directory for a contact's logs: @@ -125,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); @@ -141,18 +182,26 @@ 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); return result; } -// Ensure the directory exists, create if needed +// Ensure the directory exists, create if needed. +// Refuses to follow symlinks at the final component. static gboolean _ff_ensure_dir(const char* path) { if (g_file_test(path, G_FILE_TEST_IS_DIR)) { + // Verify it's not a symlink + struct stat st; + if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) { + log_error("flatfile: directory path is a symlink, refusing: %s", path); + return FALSE; + } return TRUE; } if (g_mkdir_with_parents(path, S_IRWXU) != 0) { @@ -162,11 +211,192 @@ _ff_ensure_dir(const char* path) return TRUE; } +// --- Escape / unescape helpers --- +// +// Message text and metadata values from remote peers can contain arbitrary +// characters including newlines, pipes and brackets. Without escaping, a +// crafted message could inject fake log lines (log injection / format +// injection). We escape on write and unescape on read. + +// Escape message body: \ -> \\, \n -> \n literal, \r -> \r literal +static char* +_ff_escape_message(const char* text) +{ + 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; + } + } + return g_string_free(out, FALSE); +} + +// Unescape message body: \\ -> \, \n -> newline, \r -> CR +static char* +_ff_unescape_message(const char* text) +{ + 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; + default: + g_string_append_c(out, '\\'); + g_string_append_c(out, *p); + break; + } + } else { + g_string_append_c(out, *p); + } + } + return g_string_free(out, FALSE); +} + +// Escape metadata value (stanza_id, archive_id, replace_id): +// these come from remote servers and may contain |, ], \, newlines. +static char* +_ff_escape_meta_value(const char* val) +{ + 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; + } + } + return g_string_free(out, FALSE); +} + +// Unescape metadata value +static char* +_ff_unescape_meta_value(const char* val) +{ + 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; + default: + g_string_append_c(out, '\\'); + g_string_append_c(out, *p); + break; + } + } else { + g_string_append_c(out, *p); + } + } + return g_string_free(out, FALSE); +} + +// --- Readline helper --- +// +// POSIX getline() for dynamic-length lines. Returns line without trailing +// newline, or NULL on EOF. Sets *truncated=TRUE if the line had no trailing +// newline at EOF (partial write detection). +static char* +_ff_readline(FILE* fp, gboolean* truncated) +{ + char* line = NULL; + size_t cap = 0; + ssize_t nread = getline(&line, &cap, fp); + if (nread == -1) { + free(line); + return NULL; + } + // Guard against pathological lines that could exhaust memory. + // getline() already allocated, so we free and skip if too long. + if (nread > FF_MAX_LINE_LEN) { + log_error("flatfile: line too long (%zd bytes), skipping", nread); + // Check newline termination before freeing + gboolean had_newline = (nread > 0 && line[nread - 1] == '\n'); + free(line); + // Skip to next newline if the overlength line wasn't newline-terminated + if (!had_newline) { + int ch; + while ((ch = fgetc(fp)) != EOF && ch != '\n') {} + } + if (truncated) + *truncated = FALSE; + // Return empty string so caller's loop continues (parse will reject it) + return g_strdup(""); + } + 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; + } + return line; +} + // --- Line format --- // // Format: {ISO8601} [{type}|{enc}|id:{stanza_id}|aid:{archive_id}|corrects:{replace_id}] {from_jid}/{resource}: {message} // -// Metadata fields after type|enc are optional. +// Message text is escaped: \\ -> \\\\, newline -> \\n, CR -> \\r +// Metadata values are escaped: |, ], \\, newline, CR // Lines starting with '#' are comments. // Empty lines are skipped. @@ -175,41 +405,162 @@ _ff_write_line(FILE* fp, const char* timestamp, const char* type, const char* en const char* stanza_id, const char* archive_id, const char* replace_id, const char* from_jid, const char* from_resource, const char* message_text) { + // Escape metadata values from remote peers + auto_gchar gchar* safe_sid = _ff_escape_meta_value(stanza_id); + auto_gchar gchar* safe_aid = _ff_escape_meta_value(archive_id); + auto_gchar gchar* safe_rid = _ff_escape_meta_value(replace_id); + // Build metadata section: [type|enc|id:...|aid:...|corrects:...] GString* meta = g_string_new("["); g_string_append(meta, type ? type : "chat"); g_string_append_c(meta, '|'); g_string_append(meta, enc ? enc : "none"); - if (stanza_id && strlen(stanza_id) > 0) { - g_string_append_printf(meta, "|id:%s", stanza_id); + if (safe_sid) { + g_string_append_printf(meta, "|id:%s", safe_sid); } - if (archive_id && strlen(archive_id) > 0) { - g_string_append_printf(meta, "|aid:%s", archive_id); + if (safe_aid) { + g_string_append_printf(meta, "|aid:%s", safe_aid); } - if (replace_id && strlen(replace_id) > 0) { - g_string_append_printf(meta, "|corrects:%s", replace_id); + if (safe_rid) { + g_string_append_printf(meta, "|corrects:%s", safe_rid); } g_string_append_c(meta, ']'); - // Build sender + // Build sender — escape ": " in the resource part to prevent + // the parser from splitting at the wrong point. + // JID bare parts (user@domain) never contain ": " but XMPP resources + // can contain arbitrary UTF-8 including ": ". GString* sender = g_string_new(from_jid ? from_jid : "unknown"); if (from_resource && strlen(from_resource) > 0) { - g_string_append_printf(sender, "/%s", from_resource); + // Escape backslash and colon-space in resource + GString* safe_res = g_string_sized_new(strlen(from_resource)); + for (const char* p = from_resource; *p; p++) { + if (*p == '\\') { + g_string_append(safe_res, "\\\\"); + } else if (*p == ':' && *(p + 1) == ' ') { + g_string_append(safe_res, "\\: "); + p++; // skip the space too + } else { + g_string_append_c(safe_res, *p); + } + } + g_string_append_printf(sender, "/%s", safe_res->str); + g_string_free(safe_res, TRUE); } - fprintf(fp, "%s %s %s: %s\n", - timestamp, - meta->str, - sender->str, - message_text ? message_text : ""); + // Escape message body to prevent log injection + char* safe_msg = _ff_escape_message(message_text); + // Build complete line and write atomically with a single write() + GString* full_line = g_string_new(NULL); + g_string_printf(full_line, "%s %s %s: %s\n", + timestamp, meta->str, sender->str, safe_msg); + + size_t to_write = full_line->len; + ssize_t written = fwrite(full_line->str, 1, to_write, fp); + if (written != (ssize_t)to_write) { + log_error("flatfile: partial write (%zd/%zu)", written, to_write); + } + + g_string_free(full_line, TRUE); + g_free(safe_msg); g_string_free(meta, TRUE); g_string_free(sender, TRUE); } +// --- Line parser helpers --- + +// Find the first occurrence of 'ch' that is not preceded by an unescaped backslash. +// Returns pointer to 'ch' in 'str', or NULL if not found. +static const char* +_ff_find_unescaped_char(const char* str, char ch) +{ + 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; + } + return NULL; +} + +// Split metadata content on unescaped '|'. Returns a NULL-terminated array. +// Caller must g_strfreev() the result. +static char** +_ff_split_meta(const char* meta) +{ + GPtrArray* arr = g_ptr_array_new(); + const char* start = meta; + 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; + start = p + 1; + } + } + g_ptr_array_add(arr, NULL); + return (char**)g_ptr_array_free(arr, FALSE); +} + +// Find first unescaped ": " (colon-space) in a string. +// Escaped form is "\: " — a backslash before the colon. +static const char* +_ff_find_unescaped_colonspace(const char* str) +{ + if (!str) + return NULL; + for (const char* p = str; *p; p++) { + if (*p == '\\' && *(p + 1)) { + p++; // skip escaped character + continue; + } + if (*p == ':' && *(p + 1) == ' ') { + return p; + } + } + return NULL; +} + +// Unescape a sender resource: "\\" -> '\', "\: " -> ": " +static char* +_ff_unescape_sender_resource(const char* res) +{ + if (!res) + return NULL; + GString* out = g_string_sized_new(strlen(res)); + for (const char* p = res; *p; p++) { + if (*p == '\\' && *(p + 1)) { + p++; + if (*p == '\\') { + g_string_append_c(out, '\\'); + } else if (*p == ':' && *(p + 1) == ' ') { + g_string_append(out, ": "); + p++; // skip the space + } else { + // Unknown escape — preserve literally + g_string_append_c(out, '\\'); + g_string_append_c(out, *p); + } + } else { + g_string_append_c(out, *p); + } + } + return g_string_free(out, FALSE); +} + // --- Line parser --- -typedef struct { +typedef struct +{ char* timestamp_str; GDateTime* timestamp; char* type; @@ -225,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); @@ -272,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; } @@ -290,12 +644,13 @@ _ff_parse_line(const char* line) result->timestamp_str = g_strndup(work, first_space - work); // Parse metadata section [...] - char* bracket_end = strchr(bracket_start, ']'); + // Use escape-aware bracket finder to handle escaped ']' in values + const char* bracket_end = _ff_find_unescaped_char(bracket_start + 1, ']'); if (bracket_end) { char* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1); - // Split by '|' - char** parts = g_strsplit(meta_content, "|", -1); + // Split by unescaped '|' + char** parts = _ff_split_meta(meta_content); if (parts) { int i = 0; for (; parts[i]; i++) { @@ -304,11 +659,11 @@ _ff_parse_line(const char* line) } else if (i == 1) { result->enc = g_strdup(parts[i]); } else if (g_str_has_prefix(parts[i], "id:")) { - result->stanza_id = g_strdup(parts[i] + 3); + result->stanza_id = _ff_unescape_meta_value(parts[i] + 3); } else if (g_str_has_prefix(parts[i], "aid:")) { - result->archive_id = g_strdup(parts[i] + 4); + result->archive_id = _ff_unescape_meta_value(parts[i] + 4); } else if (g_str_has_prefix(parts[i], "corrects:")) { - result->replace_id = g_strdup(parts[i] + 9); + result->replace_id = _ff_unescape_meta_value(parts[i] + 9); } } g_strfreev(parts); @@ -316,29 +671,31 @@ _ff_parse_line(const char* line) g_free(meta_content); // Parse sender: message after '] ' - char* after_meta = bracket_end + 1; - if (*after_meta == ' ') after_meta++; + const char* after_meta = bracket_end + 1; + if (*after_meta == ' ') + after_meta++; - // Find first ': ' which separates sender from message - char* colon = strstr(after_meta, ": "); + // Find first *unescaped* ': ' which separates sender from message. + // Resources may contain escaped \: sequences. + const char* colon = _ff_find_unescaped_colonspace(after_meta); if (colon) { - char* sender = g_strndup(after_meta, colon - after_meta); + char* raw_sender = g_strndup(after_meta, colon - after_meta); - // Split sender into jid/resource - char* slash = strchr(sender, '/'); + // Split sender into jid/resource, then unescape resource + char* slash = strchr(raw_sender, '/'); if (slash) { - result->from_jid = g_strndup(sender, slash - sender); - result->from_resource = g_strdup(slash + 1); + result->from_jid = g_strndup(raw_sender, slash - raw_sender); + result->from_resource = _ff_unescape_sender_resource(slash + 1); } else { - result->from_jid = g_strdup(sender); + result->from_jid = g_strdup(raw_sender); } - g_free(sender); + g_free(raw_sender); - result->message = g_strdup(colon + 2); + result->message = _ff_unescape_message(colon + 2); } else { // No ': ' found, treat entire rest as message with unknown sender result->from_jid = g_strdup("unknown"); - result->message = g_strdup(after_meta); + result->message = _ff_unescape_message(after_meta); } } else { // No closing bracket — malformed metadata @@ -370,10 +727,10 @@ _ff_parse_line(const char* line) result->from_jid = g_strdup(sender); } g_free(sender); - result->message = g_strdup(colon + 2); + result->message = _ff_unescape_message(colon + 2); } else { result->from_jid = g_strdup("unknown"); - result->message = g_strdup(rest); + result->message = _ff_unescape_message(rest); } } else { // No space at all — can't parse @@ -385,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; @@ -463,19 +822,36 @@ _ff_add_message(const char* type, const char* stanza_id, const char* archive_id, return; } - // Check if file is new (needs header) - gboolean is_new = !g_file_test(log_path, G_FILE_TEST_EXISTS); + // Open the file with O_NOFOLLOW to prevent symlink attacks, + // and O_APPEND for safe concurrent appends. + // Try O_CREAT|O_EXCL first to detect new files atomically (no TOCTOU). + int fd = open(log_path, O_WRONLY | O_APPEND | O_CREAT | O_EXCL | O_NOFOLLOW, + S_IRUSR | S_IWUSR); + gboolean is_new = (fd >= 0); + if (fd < 0) { + if (errno == EEXIST) { + // File already exists — open for append + fd = open(log_path, O_WRONLY | O_APPEND | O_NOFOLLOW); + } + if (fd < 0) { + // ELOOP (symlink) or other error + log_error("flatfile: could not open %s for writing (errno=%d: %s)", + log_path, errno, strerror(errno)); + g_free(effective_msg); + return; + } + } - FILE* fp = fopen(log_path, "a"); + FILE* fp = fdopen(fd, "a"); if (!fp) { - log_error("flatfile: could not open %s for writing (errno=%d)", log_path, errno); + log_error("flatfile: fdopen failed for %s (errno=%d)", log_path, errno); + close(fd); g_free(effective_msg); return; } if (is_new) { fprintf(fp, "%s", FLATFILE_HEADER); - g_chmod(log_path, S_IRUSR | S_IWUSR); } _ff_write_line(fp, date_fmt, type, _ff_get_message_enc_str(enc), @@ -524,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); @@ -577,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; @@ -601,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); @@ -612,14 +991,19 @@ _ff_read_file_messages(const char* filepath, const char* contact_barejid, fseek(fp, 0, SEEK_SET); } - char buf[8192]; - while (fgets(buf, sizeof(buf), fp)) { - // Strip trailing newline - gsize len = strlen(buf); - if (len > 0 && buf[len - 1] == '\n') buf[--len] = '\0'; + char* buf = NULL; + gboolean truncated = FALSE; + while ((buf = _ff_readline(fp, &truncated)) != NULL) { + if (truncated) { + log_warning("flatfile: truncated line at EOF in %s, skipping", filepath); + free(buf); + break; + } flatfile_parsed_line_t* pl = _ff_parse_line(buf); - if (!pl) continue; + free(buf); + if (!pl) + continue; // Check timestamp bounds if (start_time) { @@ -648,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 { @@ -691,16 +1074,25 @@ _ff_apply_lmc(GSList* parsed_lines, GSList** result) if (pl->replace_id && strlen(pl->replace_id) > 0) { flatfile_parsed_line_t* original = g_hash_table_lookup(id_map, pl->replace_id); if (original) { - // Follow chain to the root original + // Follow chain to the root original, with cycle/depth guard flatfile_parsed_line_t* root = original; - while (root->replace_id && strlen(root->replace_id) > 0) { + GHashTable* visited = g_hash_table_new(g_direct_hash, g_direct_equal); + g_hash_table_insert(visited, root, root); + int depth = 0; + while (root->replace_id && strlen(root->replace_id) > 0 && depth < FF_MAX_LMC_DEPTH) { flatfile_parsed_line_t* parent = g_hash_table_lookup(id_map, root->replace_id); - if (parent && parent != root) { + if (parent && !g_hash_table_contains(visited, parent)) { + g_hash_table_insert(visited, parent, parent); root = parent; + depth++; } else { break; } } + g_hash_table_destroy(visited); + if (depth >= FF_MAX_LMC_DEPTH) { + log_warning("flatfile: LMC correction chain too deep (>%d), ignoring", FF_MAX_LMC_DEPTH); + } g_hash_table_insert(correction_map, root, pl); g_hash_table_insert(corrections, pl, pl); } @@ -768,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); } @@ -797,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 { @@ -805,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; } @@ -826,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; } @@ -844,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; } @@ -856,25 +1253,25 @@ _flatfile_get_limits_info(const gchar* const contact_barejid, gboolean is_last) fseek(fp, 0, SEEK_SET); } - char buf[8192]; + char* buf = NULL; flatfile_parsed_line_t* found = NULL; if (!is_last) { // Find first valid line - while (fgets(buf, sizeof(buf), fp)) { - gsize len = strlen(buf); - if (len > 0 && buf[len - 1] == '\n') buf[--len] = '\0'; + while ((buf = _ff_readline(fp, NULL)) != NULL) { found = _ff_parse_line(buf); - if (found) break; + free(buf); + if (found) + break; } } else { // Find last valid line — read all and keep last - while (fgets(buf, sizeof(buf), fp)) { - gsize len = strlen(buf); - if (len > 0 && buf[len - 1] == '\n') buf[--len] = '\0'; + while ((buf = _ff_readline(fp, NULL)) != NULL) { 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; } } @@ -952,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; @@ -988,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); @@ -1005,7 +1404,7 @@ _flatfile_verify_integrity(const gchar* const contact_barejid) fseek(fp, 0, SEEK_SET); } - char buf[8192]; + char* buf = NULL; int lineno = 0; GDateTime* prev_ts = NULL; GDateTime* first_ts = NULL; @@ -1013,10 +1412,9 @@ _flatfile_verify_integrity(const gchar* const contact_barejid) gboolean has_crlf = FALSE; gboolean is_empty = TRUE; - while (fgets(buf, sizeof(buf), fp)) { + while ((buf = _ff_readline(fp, NULL)) != NULL) { lineno++; gsize len = strlen(buf); - if (len > 0 && buf[len - 1] == '\n') buf[--len] = '\0'; // CRLF check (4m) if (len > 0 && buf[len - 1] == '\r') { @@ -1025,7 +1423,10 @@ _flatfile_verify_integrity(const gchar* const contact_barejid) } // Skip empty lines and comments - if (len == 0 || buf[0] == '#') continue; + if (len == 0 || buf[0] == '#') { + free(buf); + continue; + } is_empty = FALSE; // UTF-8 validation (4k/4n) @@ -1037,6 +1438,7 @@ _flatfile_verify_integrity(const gchar* const contact_barejid) issue->line = lineno; issue->message = g_strdup_printf("Invalid UTF-8 at byte offset %ld", (long)(end - buf)); issues = g_slist_append(issues, issue); + free(buf); continue; } @@ -1061,13 +1463,18 @@ _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; } - if (!first_ts) first_ts = g_date_time_ref(pl->timestamp); - if (last_ts) g_date_time_unref(last_ts); + 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); last_ts = g_date_time_ref(pl->timestamp); // Timestamp order within file (4c) @@ -1081,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) @@ -1155,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) @@ -1170,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); @@ -1180,17 +1593,22 @@ _flatfile_verify_integrity(const gchar* const contact_barejid) fseek(fp, 0, SEEK_SET); } - char buf[8192]; + char* buf = NULL; int lineno = 0; - while (fgets(buf, sizeof(buf), fp)) { + while ((buf = _ff_readline(fp, NULL)) != NULL) { lineno++; gsize len = strlen(buf); - if (len > 0 && buf[len - 1] == '\n') buf[--len] = '\0'; - if (len > 0 && buf[len - 1] == '\r') buf[--len] = '\0'; - if (len == 0 || buf[0] == '#') 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); - if (!pl) continue; + free(buf); + if (!pl) + continue; if (pl->replace_id && strlen(pl->replace_id) > 0) { if (!g_hash_table_contains(all_stanza_ids, pl->replace_id)) { @@ -1207,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);