A lexicon-driven AppView for ATProto.
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: escape metacharacters in `find_blob_author_did`

Signed-off-by: Trezy <tre@trezy.com>

+79 -2
+21
src/db.rs
··· 45 45 sqlx_query_scalar(AssertSqlSafe(sql.to_owned())) 46 46 } 47 47 48 + /// Escape the `LIKE` wildcard metacharacters (`%`, `_`) and the escape character 49 + /// (`\`) in a value that will be embedded as a *literal* inside a `LIKE` pattern. 50 + /// Must be paired with an explicit `ESCAPE '\'` clause — SQLite has no default 51 + /// `LIKE` escape character, so without it the backslashes would be literal. 52 + pub fn escape_like(value: &str) -> String { 53 + value 54 + .replace('\\', "\\\\") 55 + .replace('%', "\\%") 56 + .replace('_', "\\_") 57 + } 58 + 48 59 /// Database backend type, auto-detected from DATABASE_URL or set via DATABASE_BACKEND. 49 60 #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] 50 61 #[serde(rename_all = "lowercase")] ··· 396 407 mod tests { 397 408 use super::*; 398 409 use chrono::Datelike; 410 + 411 + #[test] 412 + fn escape_like_escapes_metacharacters() { 413 + assert_eq!(escape_like("bafyrealcid"), "bafyrealcid"); // unchanged 414 + assert_eq!(escape_like("%"), "\\%"); 415 + assert_eq!(escape_like("a_b"), "a\\_b"); 416 + assert_eq!(escape_like("a%b_c"), "a\\%b\\_c"); 417 + // Backslash is escaped first so it can't form a spurious escape sequence. 418 + assert_eq!(escape_like("a\\%"), "a\\\\\\%"); 419 + } 399 420 400 421 // ----------------------------------------------------------------------- 401 422 // DatabaseBackend detection
+4 -2
src/spaces/db.rs
··· 981 981 space_id: &str, 982 982 blob_cid: &str, 983 983 ) -> Result<Option<String>, AppError> { 984 - let pattern = format!("%\"$link\":\"{blob_cid}\"%"); 984 + // Escape LIKE metacharacters in the caller-supplied CID so `%`/`_` are matched 985 + // literally and can't be used to match another author's record (L8). 986 + let pattern = format!("%\"$link\":\"{}\"%", crate::db::escape_like(blob_cid)); 985 987 let sql = adapt_sql( 986 - "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? LIMIT 1", 988 + "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? ESCAPE '\\' LIMIT 1", 987 989 backend, 988 990 ); 989 991 let row: Option<(String,)> = crate::db::query_as(&sql)
+54
tests/spaces_db.rs
··· 745 745 assert_eq!(fetched.record["v"], 2); 746 746 assert_eq!(fetched.cid, "cid-v2"); 747 747 } 748 + 749 + // --------------------------------------------------------------------------- 750 + // find_blob_author_did — LIKE wildcard safety (L8) 751 + // --------------------------------------------------------------------------- 752 + 753 + #[tokio::test] 754 + #[serial] 755 + async fn find_blob_author_did_treats_cid_wildcards_literally() { 756 + common::require_db!(); 757 + let pool = test_db::test_pool().await; 758 + let backend = test_db::test_backend(); 759 + test_db::truncate_all(&pool).await; 760 + 761 + let space_id = new_id(); 762 + let did = "did:plc:blob-space-owner"; 763 + let space = make_space(&space_id, did, "com.example.test", "blobspace"); 764 + spaces_db::create_space(&pool, backend, &space) 765 + .await 766 + .expect("create_space failed"); 767 + 768 + let author = "did:plc:blob-author"; 769 + let blob_cid = "bafyrealcid"; 770 + let record = SpaceRecord { 771 + uri: format!("at://{did}/space/com.example.test/blobspace/{author}/com.example.post/rk1"), 772 + space_id: space_id.clone(), 773 + author_did: author.to_string(), 774 + collection: "com.example.post".to_string(), 775 + rkey: "rk1".to_string(), 776 + record: serde_json::json!({ "image": { "$link": blob_cid } }), 777 + cid: "bafyrecordcid".to_string(), 778 + indexed_at: now_rfc3339(), 779 + }; 780 + spaces_db::insert_space_record(&pool, backend, &record) 781 + .await 782 + .expect("insert_space_record failed"); 783 + 784 + // A genuine CID still resolves to the author (escaping must not break lookups). 785 + let found = spaces_db::find_blob_author_did(&pool, backend, &space_id, blob_cid) 786 + .await 787 + .unwrap(); 788 + assert_eq!(found.as_deref(), Some(author)); 789 + 790 + // `%` must be matched literally, not as a wildcard that leaks any blob ref. 791 + let wildcard = spaces_db::find_blob_author_did(&pool, backend, &space_id, "%") 792 + .await 793 + .unwrap(); 794 + assert_eq!(wildcard, None, "'%' leaked a record via a LIKE wildcard"); 795 + 796 + // `_` must not match the single differing character of a real CID. 797 + let underscore = spaces_db::find_blob_author_did(&pool, backend, &space_id, "bafyreal_id") 798 + .await 799 + .unwrap(); 800 + assert_eq!(underscore, None, "'_' leaked a record via a LIKE wildcard"); 801 + }