Skip to main content

max / makenotwork

mt: scope verify_quotes to the quoting post's community Close the N3 cross-community matching-oracle: [quote:UUID:HASH] markers verified against get_post_body_markdown by bare id, so a member of one community could confirm text of any live post in another. Replace with get_post_body_markdown_in_community (JOIN categories, filter community_id); there is deliberately no unscoped by-id variant, so the scoping is structural rather than a lint entry.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:45 UTC
Signed with PGP, not checked
Commit: b97e123f627d26eb11373b80c1f4c18bd635f62c
Parent: ed08ae5
4 files changed, +77 insertions, -11 deletions
@@ -552,6 +552,57 @@
552 552 );
553 553 }
554 554
555 + /// Regression (audit_review.md N3 — verify_quotes scope gap): a `[quote:UUID:HASH]`
556 + /// marker must only verify against a post *in the same community*. Quoting a post
557 + /// that lives in another community — even with the exact text and correct hash —
558 + /// must be rejected, closing the cross-community matching-oracle.
559 + #[tokio::test]
560 + async fn cross_community_quote_rejected() {
561 + let mut h = TestHarness::new().await;
562 + let user_id = h.login_as("crosscommquoter").await;
563 +
564 + // Community Beta holds the target post.
565 + let comm_b = h.create_community("Beta", "beta").await;
566 + let cat_b = h.create_category(comm_b, "General", "general").await;
567 + h.add_membership(user_id, comm_b, "member").await;
568 + let secret_text = "Secret content only in community beta";
569 + let thread_b = h
570 + .create_thread_with_post(cat_b, user_id, "Beta Thread", secret_text)
571 + .await;
572 + let post_b = mt_db::queries::list_posts_in_thread(&h.db, thread_b).await.unwrap()[0].id;
573 +
574 + // Community Alpha is where the reply is composed.
575 + let comm_a = h.create_community("Alpha", "alpha").await;
576 + let cat_a = h.create_category(comm_a, "General", "general").await;
577 + h.add_membership(user_id, comm_a, "member").await;
578 + let thread_a = h
579 + .create_thread_with_post(cat_a, user_id, "Alpha Thread", "alpha op content")
580 + .await;
581 +
582 + // Quote Beta's post (exact text + correct hash) from inside Alpha.
583 + let hash = compute_quote_hash(secret_text);
584 + let body = format!(
585 + "> {}\n[quote:{}:{}]\n\nQuoting across communities",
586 + secret_text, post_b, hash
587 + );
588 + let encoded_body = urlencoding::encode(&body);
589 +
590 + let thread_url = format!("/p/alpha/general/{}", thread_a);
591 + h.client.get(&thread_url).await;
592 + let reply_url = format!("/p/alpha/general/{}/reply", thread_a);
593 + let resp = h
594 + .client
595 + .post_form(&reply_url, &format!("body={}", encoded_body))
596 + .await;
597 +
598 + assert_eq!(
599 + resp.status.as_u16(),
600 + 422,
601 + "quoting a post from another community must be rejected (N3 scope seal), got {}",
602 + resp.status
603 + );
604 + }
605 +
555 606 #[tokio::test]
556 607 async fn fabricated_quote_rejected() {
557 608 let mut h = TestHarness::new().await;
@@ -763,11 +763,20 @@
763 763 .await
764 764 }
765 765
766 - /// Fetch a post's author_id and body_markdown for quote verification.
766 + /// Fetch a post's author_id and body_markdown for quote verification, scoped to
767 + /// the quoting post's community.
768 + ///
769 + /// The `community_id` filter is not optional: a `[quote:UUID:HASH]` marker only
770 + /// verifies against a post *in the same community*, so a member of community A
771 + /// can never use quote verification as an oracle against a post in community B
772 + /// (C1 scope class, N3). There is deliberately no unscoped `by-id` variant of
773 + /// this loader — the scoping is structural, which is why it needs no
774 + /// `clippy.toml` disallowed-methods entry (cf. `get_category_in_community`).
767 775 #[tracing::instrument(skip_all)]
768 - pub async fn get_post_body_markdown(
776 + pub async fn get_post_body_markdown_in_community(
769 777 pool: &PgPool,
770 778 post_id: Uuid,
779 + community_id: Uuid,
771 780 ) -> Result<Option<(Uuid, String)>, sqlx::Error> {
772 781 // Only live posts can be quoted: a mod-removed post (or one in a soft-deleted
773 782 // thread) must fail quote verification so its text cannot be re-surfaced into
@@ -776,8 +785,11 @@
776 785 "SELECT p.author_id, p.body_markdown
777 786 FROM posts p
778 787 JOIN threads t ON t.id = p.thread_id
779 - WHERE p.id = $1 AND p.removed_at IS NULL AND t.deleted_at IS NULL",
788 + JOIN categories c ON c.id = t.category_id
789 + WHERE p.id = $1 AND c.community_id = $2
790 + AND p.removed_at IS NULL AND t.deleted_at IS NULL",
780 791 post_id,
792 + community_id,
781 793 )
782 794 .fetch_optional(pool)
783 795 .await
@@ -96,6 +96,7 @@
96 96 #[tracing::instrument(skip_all)]
97 97 pub(super) async fn verify_quotes(
98 98 db: &sqlx::PgPool,
99 + community_id: Uuid,
99 100 body: &str,
100 101 ) -> Result<Vec<Uuid>, Response> {
101 102 let refs = find_quote_refs(body);
@@ -118,10 +119,11 @@
118 119 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response());
119 120 }
120 121
121 - let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id)
122 - .await
123 - .map_err(db_error)?
124 - .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?;
122 + let (_, original_markdown) =
123 + mt_db::queries::get_post_body_markdown_in_community(db, post_id, community_id)
124 + .await
125 + .map_err(db_error)?
126 + .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?;
125 127
126 128 if !original_markdown.contains(&quoted_text) {
127 129 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response());
@@ -261,7 +263,7 @@
261 263 .map_err(db_error)?
262 264 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
263 265
264 - verify_quotes(&state.db, body).await?;
266 + verify_quotes(&state.db, community.id, body).await?;
265 267
266 268 let (body_html, mention_ids) = resolve_and_render_mentions(
267 269 &state.db, body, community.id, &slug, user.user_id, author_plus,
@@ -329,7 +331,7 @@
329 331 reject_embeds_for_free_user(body)?;
330 332 }
331 333
332 - verify_quotes(&state.db, body).await?;
334 + verify_quotes(&state.db, community.id, body).await?;
333 335
334 336 let (body_html, mention_ids) = resolve_and_render_mentions(
335 337 &state.db, body, community.id, &slug, user.user_id, author_plus,
@@ -1,6 +1,6 @@
1 1 {
2 2 "db_name": "PostgreSQL",
3 - "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n WHERE p.id = $1 AND p.removed_at IS NULL AND t.deleted_at IS NULL",
3 + "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.removed_at IS NULL AND t.deleted_at IS NULL",
4 4 "describe": {
5 5 "columns": [
6 6 {
@@ -16,6 +16,7 @@
16 16 ],
17 17 "parameters": {
18 18 "Left": [
19 + "Uuid",
19 20 "Uuid"
20 21 ]
21 22 },
@@ -24,5 +25,5 @@
24 25 false
25 26 ]
26 27 },
27 - "hash": "947028eba046c3be1515a1a0adc8102bd343d5ec539a18aa296f53c508d5e3e2"
28 + "hash": "88156f7230531c0aaa7ffdac3e70b0fc2655dfb176d446b93723a252ada349b0"
28 29 }