From a089929620b225027e40da28e859cdee065bdda5 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 8 Jul 2026 19:38:04 -0500 Subject: [PATCH] fix: escape metacharacters in `find_blob_author_did` Signed-off-by: Trezy --- src/db.rs | 21 ++++++++++++++++++ src/spaces/db.rs | 6 ++++-- tests/spaces_db.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/db.rs b/src/db.rs index e381034..c875634 100644 --- a/src/db.rs +++ b/src/db.rs @@ -45,6 +45,17 @@ where sqlx_query_scalar(AssertSqlSafe(sql.to_owned())) } +/// Escape the `LIKE` wildcard metacharacters (`%`, `_`) and the escape character +/// (`\`) in a value that will be embedded as a *literal* inside a `LIKE` pattern. +/// Must be paired with an explicit `ESCAPE '\'` clause — SQLite has no default +/// `LIKE` escape character, so without it the backslashes would be literal. +pub fn escape_like(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + /// Database backend type, auto-detected from DATABASE_URL or set via DATABASE_BACKEND. #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] @@ -397,6 +408,16 @@ mod tests { use super::*; use chrono::Datelike; + #[test] + fn escape_like_escapes_metacharacters() { + assert_eq!(escape_like("bafyrealcid"), "bafyrealcid"); // unchanged + assert_eq!(escape_like("%"), "\\%"); + assert_eq!(escape_like("a_b"), "a\\_b"); + assert_eq!(escape_like("a%b_c"), "a\\%b\\_c"); + // Backslash is escaped first so it can't form a spurious escape sequence. + assert_eq!(escape_like("a\\%"), "a\\\\\\%"); + } + // ----------------------------------------------------------------------- // DatabaseBackend detection // ----------------------------------------------------------------------- diff --git a/src/spaces/db.rs b/src/spaces/db.rs index 2ebb75c..77ef7de 100644 --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -981,9 +981,11 @@ pub async fn find_blob_author_did( space_id: &str, blob_cid: &str, ) -> Result, AppError> { - let pattern = format!("%\"$link\":\"{blob_cid}\"%"); + // Escape LIKE metacharacters in the caller-supplied CID so `%`/`_` are matched + // literally and can't be used to match another author's record (L8). + let pattern = format!("%\"$link\":\"{}\"%", crate::db::escape_like(blob_cid)); let sql = adapt_sql( - "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? LIMIT 1", + "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? ESCAPE '\\' LIMIT 1", backend, ); let row: Option<(String,)> = crate::db::query_as(&sql) diff --git a/tests/spaces_db.rs b/tests/spaces_db.rs index ae6d6d0..6ddb678 100644 --- a/tests/spaces_db.rs +++ b/tests/spaces_db.rs @@ -745,3 +745,57 @@ async fn upsert_space_record_overwrites_existing() { assert_eq!(fetched.record["v"], 2); assert_eq!(fetched.cid, "cid-v2"); } + +// --------------------------------------------------------------------------- +// find_blob_author_did — LIKE wildcard safety (L8) +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn find_blob_author_did_treats_cid_wildcards_literally() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let did = "did:plc:blob-space-owner"; + let space = make_space(&space_id, did, "com.example.test", "blobspace"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author = "did:plc:blob-author"; + let blob_cid = "bafyrealcid"; + let record = SpaceRecord { + uri: format!("at://{did}/space/com.example.test/blobspace/{author}/com.example.post/rk1"), + space_id: space_id.clone(), + author_did: author.to_string(), + collection: "com.example.post".to_string(), + rkey: "rk1".to_string(), + record: serde_json::json!({ "image": { "$link": blob_cid } }), + cid: "bafyrecordcid".to_string(), + indexed_at: now_rfc3339(), + }; + spaces_db::insert_space_record(&pool, backend, &record) + .await + .expect("insert_space_record failed"); + + // A genuine CID still resolves to the author (escaping must not break lookups). + let found = spaces_db::find_blob_author_did(&pool, backend, &space_id, blob_cid) + .await + .unwrap(); + assert_eq!(found.as_deref(), Some(author)); + + // `%` must be matched literally, not as a wildcard that leaks any blob ref. + let wildcard = spaces_db::find_blob_author_did(&pool, backend, &space_id, "%") + .await + .unwrap(); + assert_eq!(wildcard, None, "'%' leaked a record via a LIKE wildcard"); + + // `_` must not match the single differing character of a real CID. + let underscore = spaces_db::find_blob_author_did(&pool, backend, &space_id, "bafyreal_id") + .await + .unwrap(); + assert_eq!(underscore, None, "'_' leaked a record via a LIKE wildcard"); +} -- 2.51.2