fix(flatfile): harden flat-file backend against injection and traversal attacks
Some checks failed
CI Code / Check coding style (pull_request) Failing after 29s
CI Code / Check spelling (pull_request) Failing after 18s
CI Code / Linux (debian) (pull_request) Successful in 7m3s
CI Code / Code Coverage (pull_request) Successful in 4m48s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m17s
CI Code / Linux (arch) (pull_request) Has been cancelled
Some checks failed
CI Code / Check coding style (pull_request) Failing after 29s
CI Code / Check spelling (pull_request) Failing after 18s
CI Code / Linux (debian) (pull_request) Successful in 7m3s
CI Code / Code Coverage (pull_request) Successful in 4m48s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m17s
CI Code / Linux (arch) (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().
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
#include "config.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <glib.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <stdio.h>
|
||||
@@ -42,6 +44,8 @@
|
||||
|
||||
#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,46 @@ _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:
|
||||
@@ -148,11 +186,18 @@ _ff_get_log_path(const char* contact_barejid, GDateTime* dt)
|
||||
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 +207,149 @@ _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,38 +358,153 @@ _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 {
|
||||
@@ -290,12 +588,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 +603,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 +615,30 @@ _ff_parse_line(const char* line)
|
||||
g_free(meta_content);
|
||||
|
||||
// Parse sender: message after '] '
|
||||
char* after_meta = bracket_end + 1;
|
||||
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 +670,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
|
||||
@@ -463,19 +763,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),
|
||||
@@ -612,13 +929,17 @@ _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);
|
||||
free(buf);
|
||||
if (!pl) continue;
|
||||
|
||||
// Check timestamp bounds
|
||||
@@ -691,16 +1012,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);
|
||||
}
|
||||
@@ -856,23 +1186,21 @@ _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);
|
||||
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);
|
||||
found = pl;
|
||||
@@ -1005,7 +1333,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 +1341,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 +1352,7 @@ _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 +1364,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;
|
||||
}
|
||||
|
||||
@@ -1063,9 +1391,12 @@ _flatfile_verify_integrity(const gchar* const contact_barejid)
|
||||
issue->line = lineno;
|
||||
issue->message = g_strdup("Unparseable line");
|
||||
issues = g_slist_append(issues, issue);
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1180,16 +1511,16 @@ _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[0] == '#') { free(buf); continue; }
|
||||
|
||||
flatfile_parsed_line_t* pl = _ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl) continue;
|
||||
|
||||
if (pl->replace_id && strlen(pl->replace_id) > 0) {
|
||||
|
||||
Reference in New Issue
Block a user