Skip to content
This repository was archived by the owner on Apr 4, 2026. It is now read-only.

Commit efb669d

Browse files
fix: sanitize null bytes from messages.body JSON for PostgreSQL
Protobuf strings are length-delimited and can contain null bytes. Farcaster Hub accepts casts with null bytes, but PostgreSQL jsonb rejects \u0000 escape sequences. This walks the serde_json::Value tree and strips null bytes from decoded strings, avoiding the pitfall of simple string replacement which can corrupt valid escape sequences like \\u0000.
1 parent d5d40ee commit efb669d

3 files changed

Lines changed: 106 additions & 4 deletions

File tree

src/core/util.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ pub fn calculate_message_hash(data_bytes: &[u8]) -> Vec<u8> {
6666
blake3::hash(data_bytes).as_bytes()[0..20].to_vec()
6767
}
6868

69+
/// Strips null bytes from all strings in a JSON Value tree for PostgreSQL jsonb insertion.
70+
///
71+
/// PostgreSQL jsonb rejects `\u0000`. Simple string replacement on serialized JSON is unsafe
72+
/// because it can corrupt valid escape sequences like `\\u0000`. This function walks the
73+
/// parsed Value tree and strips null bytes from decoded strings, then re-serializes.
74+
pub fn sanitize_json_for_postgres(value: serde_json::Value) -> serde_json::Value {
75+
match value {
76+
serde_json::Value::String(s) => {
77+
if s.contains('\0') {
78+
serde_json::Value::String(s.replace('\0', ""))
79+
} else {
80+
serde_json::Value::String(s)
81+
}
82+
},
83+
serde_json::Value::Array(arr) => {
84+
serde_json::Value::Array(arr.into_iter().map(sanitize_json_for_postgres).collect())
85+
},
86+
serde_json::Value::Object(map) => serde_json::Value::Object(
87+
map.into_iter().map(|(k, v)| (k, sanitize_json_for_postgres(v))).collect(),
88+
),
89+
other => other, // Null, Bool, Number - pass through
90+
}
91+
}
92+
6993
#[cfg(test)]
7094
mod tests {
7195
use super::*;
@@ -143,4 +167,73 @@ mod tests {
143167
let hash2 = calculate_message_hash(data);
144168
assert_eq!(hash, hash2);
145169
}
170+
171+
#[test]
172+
fn test_sanitize_json_no_null_bytes() {
173+
let value = serde_json::json!({"text": "Hello, world!"});
174+
let result = sanitize_json_for_postgres(value.clone());
175+
assert_eq!(result, value);
176+
}
177+
178+
#[test]
179+
fn test_sanitize_json_with_null_byte() {
180+
// String containing actual null byte
181+
let value = serde_json::json!({"text": "Hello\0World"});
182+
let result = sanitize_json_for_postgres(value);
183+
assert_eq!(result, serde_json::json!({"text": "HelloWorld"}));
184+
}
185+
186+
#[test]
187+
fn test_sanitize_json_nested() {
188+
let value = serde_json::json!({
189+
"outer": {
190+
"inner": "has\0null"
191+
},
192+
"array": ["a\0b", "clean"]
193+
});
194+
let result = sanitize_json_for_postgres(value);
195+
assert_eq!(
196+
result,
197+
serde_json::json!({
198+
"outer": {"inner": "hasnull"},
199+
"array": ["ab", "clean"]
200+
})
201+
);
202+
}
203+
204+
#[test]
205+
fn test_sanitize_json_preserves_escaped_backslash() {
206+
// This is the key test - \\u0000 in source becomes \u0000 in the string
207+
// (a literal backslash followed by u0000), which should NOT be corrupted
208+
let value = serde_json::json!({"text": r"\u0000"});
209+
let result = sanitize_json_for_postgres(value.clone());
210+
// The string contains literal \u0000 chars, no null byte, so unchanged
211+
assert_eq!(result, value);
212+
}
213+
214+
#[test]
215+
fn test_sanitize_json_numbers_unchanged() {
216+
let value = serde_json::json!({"count": 42, "rate": 1.5});
217+
let result = sanitize_json_for_postgres(value.clone());
218+
assert_eq!(result, value);
219+
}
220+
221+
#[test]
222+
fn test_sanitize_json_realistic_message() {
223+
// Simulate a protobuf message with null byte in text
224+
let value = serde_json::json!({
225+
"fid": 123,
226+
"text": "gm\0everyone",
227+
"type": 1
228+
});
229+
let result = sanitize_json_for_postgres(value);
230+
assert_eq!(
231+
result,
232+
serde_json::json!({
233+
"fid": 123,
234+
"text": "gmeveryone",
235+
"type": 1
236+
})
237+
);
238+
}
146239
}

src/database/batch.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{
2-
core::normalize::NormalizedEmbed,
2+
core::{normalize::NormalizedEmbed, util::sanitize_json_for_postgres},
33
database::models::Fid,
44
proto::{
55
Message,
@@ -400,7 +400,10 @@ impl<'a> BatchInserter<'a> {
400400
if let Some(data) = &msg.data {
401401
let ts = convert_timestamp(data.timestamp);
402402
let raw_data = msg.data_bytes.as_deref().unwrap_or_default();
403-
let body_json = serde_json::to_value(data).unwrap_or(serde_json::Value::Null);
403+
// Sanitize null bytes from JSON - PostgreSQL jsonb rejects \u0000
404+
let body_json = serde_json::to_value(data)
405+
.map(sanitize_json_for_postgres)
406+
.unwrap_or(serde_json::Value::Null);
404407

405408
query = query
406409
.bind(data.fid as i64)

src/processor/database.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
use crate::{
2-
core::{normalize::NormalizedEmbed, util::from_farcaster_time},
2+
core::{
3+
normalize::NormalizedEmbed,
4+
util::{from_farcaster_time, sanitize_json_for_postgres},
5+
},
36
database::batch::BatchInserter,
47
hub::subscriber::{PostProcessHandler, PreProcessHandler},
58
metrics,
@@ -1066,6 +1069,9 @@ impl DatabaseProcessor {
10661069
if store_messages {
10671070
let raw_data = msg.data_bytes.as_deref().unwrap_or_default();
10681071

1072+
// Sanitize null bytes from JSON - PostgreSQL jsonb rejects \u0000
1073+
let body_json = sanitize_json_for_postgres(serde_json::to_value(data)?);
1074+
10691075
// Store message in messages table with transaction
10701076
// Using ON CONFLICT (hash) DO NOTHING to avoid duplicate key errors
10711077
// and unnecessary retry cycles when reprocessing messages
@@ -1085,7 +1091,7 @@ impl DatabaseProcessor {
10851091
msg.hash_scheme as i16,
10861092
msg.signature_scheme as i16,
10871093
&msg.signer,
1088-
serde_json::to_value(data)?,
1094+
body_json,
10891095
raw_data,
10901096
match operation {
10911097
"delete" => Some(OffsetDateTime::now_utc()),

0 commit comments

Comments
 (0)